From 45469c8fed0cc7f8cc9bf6002f03a0d42250c561 Mon Sep 17 00:00:00 2001 From: liaozhaorun <1300336796@qq.com> Date: Sat, 11 Apr 2026 01:53:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(factorminer):=20=E6=96=B0=E5=A2=9E=20Local?= =?UTF-8?q?FactorEvaluator=20=E9=9B=86=E6=88=90=E5=88=B0=E8=AF=84=E4=BC=B0?= =?UTF-8?q?=E7=AE=A1=E7=BA=BF=20-=20=E6=96=B0=E5=A2=9E=20LocalFactorEvalua?= =?UTF-8?q?tor=20=E7=B1=BB=E5=B0=81=E8=A3=85=20FactorEngine=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=20(M,T)=20=E7=9F=A9=E9=98=B5=E8=BE=93?= =?UTF-8?q?=E5=87=BA=20-=20evaluate=5Ffactors=5Fwith=5Fevaluator()=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=96=B0=E8=AF=84=E4=BC=B0=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=20-=20ValidationPipeline=20=E4=BC=98=E5=85=88=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20evaluator=20=E8=AE=A1=E7=AE=97=E4=BF=A1=E5=8F=B7=20?= =?UTF-8?q?-=20=E6=96=B0=E5=A2=9E=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + src/config/settings.py | 3 + src/factorminer/__init__.py | 4 - src/factorminer/agent/__init__.py | 2 - src/factorminer/agent/llm_interface.py | 131 +-- src/factorminer/benchmark/ablation.py | 95 ++- src/factorminer/benchmark/helix_benchmark.py | 35 +- src/factorminer/benchmark/runtime.py | 233 +++-- src/factorminer/cli.py | 311 +++++-- src/factorminer/core/factor_library.py | 259 +++--- src/factorminer/core/library_io.py | 22 +- src/factorminer/core/ralph_loop.py | 217 +++-- src/factorminer/data/__init__.py | 8 - src/factorminer/data/mock_data.py | 323 ------- src/factorminer/evaluation/local_engine.py | 10 +- src/factorminer/main.py | 263 ++++++ .../output/checkpoint/helix_state.json | 8 - .../output/checkpoint/library.json | 7 - .../output/checkpoint/library_signals.npz | Bin 4140 -> 0 bytes .../output/checkpoint/loop_state.json | 13 - src/factorminer/output/checkpoint/memory.json | 64 -- .../output/checkpoint/session.json | 56 -- src/factorminer/output/factor_library.json | 13 - .../output/factor_library_signals.npz | Bin 31289 -> 0 bytes src/factorminer/output/mining_batches.jsonl | 58 -- src/factorminer/output/session.json | 56 -- src/factorminer/output/session_log.json | 73 -- src/factorminer/run_demo.py | 517 ----------- src/factorminer/run_phase2_benchmark.py | 803 ------------------ src/factorminer/tests/test_auto_inventor.py | 4 +- src/factorminer/tests/test_debate.py | 10 +- src/factorminer/tests/test_helix_loop.py | 16 +- src/factorminer/tests/test_provenance.py | 4 +- src/factorminer/tests/test_ralph_loop.py | 26 +- tests/test_factorminer_e2e.py | 117 +++ 35 files changed, 1176 insertions(+), 2586 deletions(-) delete mode 100644 src/factorminer/data/mock_data.py create mode 100644 src/factorminer/main.py delete mode 100644 src/factorminer/output/checkpoint/helix_state.json delete mode 100644 src/factorminer/output/checkpoint/library.json delete mode 100644 src/factorminer/output/checkpoint/library_signals.npz delete mode 100644 src/factorminer/output/checkpoint/loop_state.json delete mode 100644 src/factorminer/output/checkpoint/memory.json delete mode 100644 src/factorminer/output/checkpoint/session.json delete mode 100644 src/factorminer/output/factor_library.json delete mode 100644 src/factorminer/output/factor_library_signals.npz delete mode 100644 src/factorminer/output/mining_batches.jsonl delete mode 100644 src/factorminer/output/session.json delete mode 100644 src/factorminer/output/session_log.json delete mode 100644 src/factorminer/run_demo.py delete mode 100644 src/factorminer/run_phase2_benchmark.py create mode 100644 tests/test_factorminer_e2e.py diff --git a/.gitignore b/.gitignore index ec9e6df..411e75f 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,4 @@ src/training/output/* /.sisyphus/ /src/experiment/output/ /src/experiment/models/ +/src/factorminer/output/ diff --git a/src/config/settings.py b/src/config/settings.py index cbead37..b54154d 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -25,6 +25,9 @@ class Settings(BaseSettings): # Tushare API 配置 tushare_token: str = "" + # Anthropic API 配置(FactorMiner 使用) + anthropic_api_key: Optional[str] = None + # 数据存储配置 root_path: str = "" # 项目根路径,默认自动检测 data_path: str = "data" # 数据存储路径,相对于 root_path diff --git a/src/factorminer/__init__.py b/src/factorminer/__init__.py index 98ae811..ae2434e 100644 --- a/src/factorminer/__init__.py +++ b/src/factorminer/__init__.py @@ -17,10 +17,8 @@ from src.factorminer.core import ( try_parse, ) from src.factorminer.data import ( - MockConfig, TensorConfig, build_tensor, - generate_mock_data, load_market_data, preprocess, ) @@ -105,8 +103,6 @@ __all__ = [ # data "load_market_data", "preprocess", - "MockConfig", - "generate_mock_data", "TensorConfig", "build_tensor", # evaluation diff --git a/src/factorminer/agent/__init__.py b/src/factorminer/agent/__init__.py index eef2289..5310f23 100644 --- a/src/factorminer/agent/__init__.py +++ b/src/factorminer/agent/__init__.py @@ -5,7 +5,6 @@ from src.factorminer.agent.llm_interface import ( AnthropicProvider, GoogleProvider, LLMProvider, - MockProvider, OpenAIProvider, create_provider, ) @@ -45,7 +44,6 @@ __all__ = [ "OpenAIProvider", "AnthropicProvider", "GoogleProvider", - "MockProvider", "create_provider", # Parsing "CandidateFactor", diff --git a/src/factorminer/agent/llm_interface.py b/src/factorminer/agent/llm_interface.py index ba4ea65..134d8dd 100644 --- a/src/factorminer/agent/llm_interface.py +++ b/src/factorminer/agent/llm_interface.py @@ -126,7 +126,7 @@ class AnthropicProvider(LLMProvider): "anthropic package is required for AnthropicProvider. " "Install with: pip install anthropic" ) - self._client = anthropic.Anthropic(api_key=self.api_key) + self._client = anthropic.Anthropic(api_key=self.api_key, base_url='https://api.minimaxi.com/anthropic') return self._client def generate( @@ -137,8 +137,12 @@ class AnthropicProvider(LLMProvider): max_tokens: int = 32000, ) -> str: client = self._get_client() - logger.debug("Anthropic request: model=%s thinking=%s effort=%s", - self.model, self.use_thinking, self.effort) + logger.debug( + "Anthropic request: model=%s thinking=%s effort=%s", + self.model, + self.use_thinking, + self.effort, + ) kwargs: dict = { "model": self.model, @@ -154,16 +158,13 @@ class AnthropicProvider(LLMProvider): else: kwargs["temperature"] = temperature - response = client.messages.create(**kwargs) - - # Extract text from response, skipping thinking blocks - text_parts = [] - for block in response.content: - if hasattr(block, "text"): - text_parts.append(block.text) - text = "\n".join(text_parts) if text_parts else "" - logger.debug("Anthropic response: %d chars", len(text)) - return text + text_parts: list[str] = [] + with client.messages.stream(**kwargs) as stream: + for text in stream.text_stream: + text_parts.append(text) + full_text = "".join(text_parts) + logger.debug("Anthropic response: %d chars", len(full_text)) + return full_text @property def provider_name(self) -> str: @@ -224,101 +225,6 @@ class GoogleProvider(LLMProvider): return f"google/{self.model}" -class MockProvider(LLMProvider): - """Deterministic provider for testing without API calls. - - Returns predefined factor formulas that exercise diverse operator - combinations. Useful for unit tests and integration testing. - """ - - MOCK_FACTORS = [ - ("momentum_reversal", "Neg(CsRank(Delta($close, 5)))"), - ("volume_surprise", "CsZScore(Div(Sub($volume, Mean($volume, 20)), Std($volume, 20)))"), - ("price_range_ratio", "Div(Sub($high, $low), Add($high, $low))"), - ("vwap_deviation", "CsRank(Div(Sub($close, $vwap), $vwap))"), - ("return_skew", "Neg(Skew($returns, 20))"), - ("intraday_momentum", "CsRank(Div(Sub($close, $open), Sub($high, $low)))"), - ("volume_price_corr", "Neg(Corr($volume, $close, 10))"), - ("amt_acceleration", "CsZScore(Delta(Mean($amt, 5), 5))"), - ("close_high_ratio", "CsRank(Sub(Div($close, TsMax($high, 20)), 1))"), - ("smooth_return", "Neg(CsRank(EMA($returns, 10)))"), - ("volatility_ratio", "Div(Std($returns, 5), Std($returns, 20))"), - ("mean_reversion", "Neg(CsZScore(Div(Sub($close, SMA($close, 20)), SMA($close, 20))))"), - ("volume_trend", "CsRank(TsLinRegSlope($volume, 20))"), - ("price_position", "CsRank(Div(Sub($close, TsMin($close, 20)), Sub(TsMax($close, 20), TsMin($close, 20))))"), - ("amt_volume_div", "CsRank(Neg(Corr(CsRank($amt), CsRank($volume), 10)))"), - ("weighted_return", "CsZScore(WMA($returns, 10))"), - ("high_low_decay", "Neg(Decay(Div(Sub($high, $low), $close), 10))"), - ("residual_vol", "CsRank(Std(Resid($close, $volume, 20), 10))"), - ("open_gap", "CsZScore(Div(Sub($open, Delay($close, 1)), Delay($close, 1)))"), - ("log_turnover", "Neg(CsRank(Log(Div($amt, $volume))))"), - ("beta_momentum", "CsRank(Mul(Beta($returns, $volume, 20), Delta($close, 10)))"), - ("rank_reversal", "Neg(CsRank(Sum($returns, 5)))"), - ("kurtosis_signal", "CsZScore(Neg(Kurt($returns, 20)))"), - ("vwap_trend", "CsRank(TsLinRegSlope(Div($close, $vwap), 20))"), - ("adaptive_mean", "CsRank(Div(Sub($close, KAMA($close, 10)), Std($close, 10)))"), - ("cumulative_flow", "CsZScore(CsRank(Delta(CumSum(Mul($volume, Sign(Delta($close, 1)))), 5)))"), - ("range_breakout", "CsRank(Div(Sub($close, TsMin($low, 10)), Std($close, 10)))"), - ("hull_deviation", "Neg(CsRank(Div(Sub($close, HMA($close, 20)), $close)))"), - ("conditional_vol", "CsZScore(IfElse(Greater($returns, 0), Std($returns, 10), Neg(Std($returns, 10))))"), - ("dema_crossover", "CsRank(Sub(DEMA($close, 5), DEMA($close, 20)))"), - ("ts_rank_volume", "Neg(CsRank(TsRank($volume, 20)))"), - ("median_price", "CsZScore(Div(Sub($close, Median($close, 20)), Median($close, 20)))"), - ("argmax_timing", "CsRank(Neg(TsArgMax($close, 20)))"), - ("log_return_sum", "Neg(CsRank(Sum(LogReturn($close, 1), 10)))"), - ("price_cov", "CsZScore(Neg(Cov($close, $volume, 20)))"), - ("inv_volatility", "CsRank(Inv(Std($returns, 20)))"), - ("squared_return", "Neg(CsRank(Mean(Square($returns), 10)))"), - ("abs_return_ratio", "CsRank(Div(Abs(Delta($close, 1)), Mean(Abs(Delta($close, 1)), 20)))"), - ("quantile_signal", "CsZScore(Quantile($returns, 20, 0.75))"), - ("neutralized_mom", "CsNeutralize(Delta($close, 10))"), - ] - - def __init__(self, cycle: bool = True) -> None: - self._cycle = cycle - self._call_count = 0 - - def generate( - self, - system_prompt: str, - user_prompt: str, - temperature: float = 0.8, - max_tokens: int = 4096, - ) -> str: - # Parse batch_size from user_prompt if present - batch_size = 40 - for line in user_prompt.split("\n"): - if "generate" in line.lower() and "candidate" in line.lower(): - for word in line.split(): - if word.isdigit(): - batch_size = int(word) - break - - batch_size = min(batch_size, len(self.MOCK_FACTORS)) - - start = self._call_count * batch_size - if self._cycle: - indices = [ - (start + i) % len(self.MOCK_FACTORS) - for i in range(batch_size) - ] - else: - indices = list(range(min(batch_size, len(self.MOCK_FACTORS)))) - - self._call_count += 1 - - lines = [] - for idx, factor_idx in enumerate(indices, 1): - name, formula = self.MOCK_FACTORS[factor_idx] - lines.append(f"{idx}. {name}: {formula}") - - return "\n".join(lines) - - @property - def provider_name(self) -> str: - return "mock" - - # --------------------------------------------------------------------------- # Factory # --------------------------------------------------------------------------- @@ -327,7 +233,6 @@ _PROVIDER_MAP: Dict[str, type] = { "openai": OpenAIProvider, "anthropic": AnthropicProvider, "google": GoogleProvider, - "mock": MockProvider, } @@ -356,10 +261,12 @@ def create_provider(config: Dict[str, Any]) -> LLMProvider: ) kwargs: Dict[str, Any] = {} - if "model" in config and provider_name != "mock": + if "model" in config: kwargs["model"] = config["model"] - if "api_key" in config and provider_name != "mock": + if "api_key" in config: kwargs["api_key"] = config["api_key"] - logger.info("Creating LLM provider: %s (kwargs=%s)", provider_name, list(kwargs.keys())) + logger.info( + "Creating LLM provider: %s (kwargs=%s)", provider_name, list(kwargs.keys()) + ) return cls(**kwargs) diff --git a/src/factorminer/benchmark/ablation.py b/src/factorminer/benchmark/ablation.py index 31d6a79..463ec09 100644 --- a/src/factorminer/benchmark/ablation.py +++ b/src/factorminer/benchmark/ablation.py @@ -32,7 +32,6 @@ import pandas as pd import src.factorminer.core.helix_loop as helix_loop_module import src.factorminer.core.ralph_loop as ralph_loop_module from src.factorminer.agent.debate import DebateConfig as RuntimeDebateConfig -from src.factorminer.agent.llm_interface import MockProvider from src.factorminer.benchmark.helix_benchmark import AblationResult, MethodResult from src.factorminer.core.config import MiningConfig from src.factorminer.core.helix_loop import HelixLoop @@ -168,7 +167,9 @@ def _build_runtime_dataset(data: dict) -> EvaluationDataset: # The caller populates train/test splits by passing a merged train+test view. return EvaluationDataset( - data_dict={key: np.asarray(data[key], dtype=np.float64) for key in feature_keys}, + data_dict={ + key: np.asarray(data[key], dtype=np.float64) for key in feature_keys + }, data_tensor=data_tensor, returns=returns, timestamps=timestamps, @@ -273,9 +274,15 @@ def _build_phase2_configs(flags: Dict[str, bool]) -> Dict[str, Any]: """Translate ablation flags into real HelixLoop runtime configs.""" return { "debate_config": RuntimeDebateConfig() if flags.get("debate", True) else None, - "causal_config": RuntimeCausalConfig(enabled=True) if flags.get("causal", True) else None, - "regime_config": RuntimeRegimeConfig(enabled=True) if flags.get("regime", True) else None, - "capacity_config": RuntimeCapacityConfig(enabled=True) if flags.get("capacity", True) else None, + "causal_config": RuntimeCausalConfig(enabled=True) + if flags.get("causal", True) + else None, + "regime_config": RuntimeRegimeConfig(enabled=True) + if flags.get("regime", True) + else None, + "capacity_config": RuntimeCapacityConfig(enabled=True) + if flags.get("capacity", True) + else None, "significance_config": ( RuntimeSignificanceConfig(enabled=True) if flags.get("significance", True) @@ -336,7 +343,9 @@ def _compute_avg_abs_rho(artifacts) -> float: return 0.0 corr = np.abs( - np.corrcoef([artifact.split_signals["train"].reshape(-1) for artifact in artifacts]) + np.corrcoef( + [artifact.split_signals["train"].reshape(-1) for artifact in artifacts] + ) ) if corr.ndim != 2: return 0.0 @@ -444,9 +453,16 @@ def _evaluate_runtime_library( run_id=0, ) result.n_factors = benchmark_library.size - result.admission_rate = benchmark_library.size / max(benchmark_stats.get("succeeded", 0), 1) + result.admission_rate = benchmark_library.size / max( + benchmark_stats.get("succeeded", 0), 1 + ) result.avg_abs_rho = _compute_avg_abs_rho(frozen) - return result, payload, benchmark_library.size, int(benchmark_stats.get("succeeded", 0)) + return ( + result, + payload, + benchmark_library.size, + int(benchmark_stats.get("succeeded", 0)), + ) class AblatedMethodRunner: @@ -499,7 +515,7 @@ class AblatedMethodRunner: config=mining_cfg, data_tensor=loop_dataset.data_tensor, returns=np.asarray(train_data["forward_returns"], dtype=np.float64), - llm_provider=self.llm_provider or MockProvider(), + llm_provider=self.llm_provider, memory=memory, library=FactorLibrary( correlation_threshold=self.correlation_threshold, @@ -516,11 +532,16 @@ class AblatedMethodRunner: regime_config=phase2["regime_config"], capacity_config=phase2["capacity_config"], significance_config=phase2["significance_config"], - volume=np.asarray(train_data.get("$amt", train_data["forward_returns"]), dtype=np.float64) + volume=np.asarray( + train_data.get("$amt", train_data["forward_returns"]), + dtype=np.float64, + ) if "$amt" in train_data else None, ) - with _patched_memory_hooks(self._cfg.get("memory", True) and self._cfg.get("online_memory", True)): + with _patched_memory_hooks( + self._cfg.get("memory", True) and self._cfg.get("online_memory", True) + ): loop.run( target_size=target_library_size, max_iterations=max_iterations, @@ -540,11 +561,13 @@ class AblatedMethodRunner: benchmark_dataset = _build_combined_dataset(data, test_data) loop, mining_cfg = self._run_loop(train_data=data, n_factors=n_factors) - result, payload, benchmark_library_size, benchmark_succeeded = _evaluate_runtime_library( - loop.library, - benchmark_dataset, - mining_cfg, - target_library_size=n_factors, + result, payload, benchmark_library_size, benchmark_succeeded = ( + _evaluate_runtime_library( + loop.library, + benchmark_dataset, + mining_cfg, + target_library_size=n_factors, + ) ) elapsed = time.perf_counter() - t0 @@ -677,23 +700,29 @@ class AblationStudy: else: interpretation = "Hurts (unexpected direction)" - rows.append({ - "component": component, - "ablation_config": ablation_key, - "ic_full": full.library_ic, - "ic_ablated": ablated.library_ic, - "ic_contribution": ic_contrib, - "ic_contribution_pct": ic_contrib / max(full.library_ic, 1e-6) * 100, - "icir_full": full.library_icir, - "icir_ablated": ablated.library_icir, - "icir_contribution": icir_contrib, - "admission_rate_delta": adm_delta, - "interpretation": interpretation, - }) + rows.append( + { + "component": component, + "ablation_config": ablation_key, + "ic_full": full.library_ic, + "ic_ablated": ablated.library_ic, + "ic_contribution": ic_contrib, + "ic_contribution_pct": ic_contrib + / max(full.library_ic, 1e-6) + * 100, + "icir_full": full.library_icir, + "icir_ablated": ablated.library_icir, + "icir_contribution": icir_contrib, + "admission_rate_delta": adm_delta, + "interpretation": interpretation, + } + ) df = pd.DataFrame(rows) if not df.empty: - df = df.sort_values("ic_contribution", ascending=False).reset_index(drop=True) + df = df.sort_values("ic_contribution", ascending=False).reset_index( + drop=True + ) return df def to_latex_table(self, result: AblationResult) -> str: @@ -739,7 +768,9 @@ class AblationStudy: full = result.results.get("full") if full: - print(f"\n FULL System: IC={full.library_ic:.4f} ICIR={full.library_icir:.3f}") + print( + f"\n FULL System: IC={full.library_ic:.4f} ICIR={full.library_icir:.3f}" + ) print() header = ( @@ -783,7 +814,7 @@ def run_full_ablation_study( cfgs = configs_to_run or list(ABLATION_CONFIGS.keys()) print(f" Running {len(cfgs)} ablation configurations through real loops...") - study = AblationStudy(seed=seed, llm_provider=MockProvider()) + study = AblationStudy(seed=seed, llm_provider=None) result = study.run_ablation( data=data, train_period=(0, train_end), diff --git a/src/factorminer/benchmark/helix_benchmark.py b/src/factorminer/benchmark/helix_benchmark.py index 0011653..dbbe9cf 100644 --- a/src/factorminer/benchmark/helix_benchmark.py +++ b/src/factorminer/benchmark/helix_benchmark.py @@ -1410,31 +1410,24 @@ class HelixBenchmark: cloned._raw = copy.deepcopy(getattr(cfg, "_raw", {})) return cloned - def _build_runtime_provider(self, cfg, mock: bool): - from src.factorminer.agent.llm_interface import MockProvider, create_provider + def _build_runtime_provider(self, cfg): + from src.factorminer.agent.llm_interface import create_provider - if mock: - return MockProvider() - - provider_name = getattr(cfg.llm, "provider", "mock") - model_name = getattr(cfg.llm, "model", "mock") + provider_name = getattr(cfg.llm, "provider", None) + model_name = getattr(cfg.llm, "model", None) api_key = None if hasattr(cfg, "_raw"): api_key = getattr(cfg, "_raw", {}).get("llm", {}).get("api_key") - if provider_name == "mock" or not api_key: - return MockProvider() + if not provider_name or provider_name == "mock" or not api_key: + raise ValueError("LLM provider not configured") - try: - return create_provider( - { - "provider": provider_name, - "model": model_name, - "api_key": api_key, - } - ) - except Exception as exc: # pragma: no cover - defensive fallback - logger.warning("Falling back to MockProvider: %s", exc) - return MockProvider() + return create_provider( + { + "provider": provider_name, + "model": model_name, + "api_key": api_key, + } + ) def _build_runtime_mining_config(self, cfg, output_dir: Path, mock: bool): from src.factorminer.core.config import MiningConfig as RuntimeMiningConfig @@ -1563,7 +1556,7 @@ class HelixBenchmark: output_dir.mkdir(parents=True, exist_ok=True) runtime_cfg = self._build_runtime_mining_config(cfg, output_dir, mock=mock) - provider = self._build_runtime_provider(cfg, mock=mock) + provider = self._build_runtime_provider(cfg) runtime_kwargs = { "config": runtime_cfg, diff --git a/src/factorminer/benchmark/runtime.py b/src/factorminer/benchmark/runtime.py index 3b8e07d..b6c92be 100644 --- a/src/factorminer/benchmark/runtime.py +++ b/src/factorminer/benchmark/runtime.py @@ -154,7 +154,9 @@ def _session_summary(path: Path) -> dict[str, Any] | None: return {"path": str(path), "load_error": str(exc)} -def _catalog_provenance(baseline: str, candidate_count: int, seed: int) -> dict[str, Any]: +def _catalog_provenance( + baseline: str, candidate_count: int, seed: int +) -> dict[str, Any]: return { "kind": "catalog", "source": baseline, @@ -247,15 +249,16 @@ def _runtime_manifest_value( return dict(value) if isinstance(value, dict) else {} -def _build_runtime_provider(cfg, *, mock: bool): +def _build_runtime_provider(cfg): """Create the benchmark-time LLM provider.""" - from src.factorminer.agent.llm_interface import MockProvider, create_provider + from src.factorminer.agent.llm_interface import create_provider - if mock or getattr(cfg.llm, "provider", "mock") == "mock": - return MockProvider() + provider_name = getattr(cfg.llm, "provider", None) + if not provider_name or provider_name == "mock": + raise ValueError("LLM provider not configured") provider_cfg = { - "provider": cfg.llm.provider, + "provider": provider_name, "model": cfg.llm.model, } raw_llm_cfg = getattr(cfg, "_raw", {}).get("llm", {}) @@ -271,16 +274,16 @@ def _filter_dataclass_kwargs(source, target_cls): target_fields = {f.name for f in fields(target_cls)} source_fields = getattr(source, "__dataclass_fields__", {}) return { - name: getattr(source, name) - for name in source_fields - if name in target_fields + name: getattr(source, name) for name in source_fields if name in target_fields } def _build_phase2_runtime_kwargs(cfg) -> dict[str, Any]: """Build runtime Phase 2 configs from the hierarchical benchmark config.""" from src.factorminer.evaluation.causal import CausalConfig as RuntimeCausalConfig - from src.factorminer.evaluation.capacity import CapacityConfig as RuntimeCapacityConfig + from src.factorminer.evaluation.capacity import ( + CapacityConfig as RuntimeCapacityConfig, + ) from src.factorminer.evaluation.regime import RegimeConfig as RuntimeRegimeConfig from src.factorminer.evaluation.significance import ( SignificanceConfig as RuntimeSignificanceConfig, @@ -323,7 +326,9 @@ def _build_phase2_runtime_kwargs(cfg) -> dict[str, Any]: significance_config = None if cfg.phase2.significance.enabled: significance_config = RuntimeSignificanceConfig( - **_filter_dataclass_kwargs(cfg.phase2.significance, RuntimeSignificanceConfig) + **_filter_dataclass_kwargs( + cfg.phase2.significance, RuntimeSignificanceConfig + ) ) return { @@ -433,9 +438,7 @@ def _build_runtime_loop_config( ), output_dir=str(output_dir), backend=str( - runtime_manifest.get( - "backend", getattr(cfg.evaluation, "backend", "numpy") - ) + runtime_manifest.get("backend", getattr(cfg.evaluation, "backend", "numpy")) ), gpu_device=str( runtime_manifest.get( @@ -445,7 +448,9 @@ def _build_runtime_loop_config( signal_failure_policy=str( runtime_manifest.get( "signal_failure_policy", - "synthetic" if mock else getattr(cfg.evaluation, "signal_failure_policy", "reject"), + "synthetic" + if mock + else getattr(cfg.evaluation, "signal_failure_policy", "reject"), ) ), ) @@ -510,7 +515,14 @@ def _real_mining_loop_type(baseline: str, runtime_manifest: dict[str, Any]) -> s loop_type = str(runtime_manifest.get("loop_type", "")).strip().lower() if loop_type in {"ralph", "helix"}: return loop_type - if baseline in {"helix_phase2", "helix_no_memory", "helix_no_debate", "helix_no_significance", "helix_no_capacity", "helix_no_regime"}: + if baseline in { + "helix_phase2", + "helix_no_memory", + "helix_no_debate", + "helix_no_significance", + "helix_no_capacity", + "helix_no_regime", + }: return "helix" if baseline in {"factor_miner", "factor_miner_no_memory", "ralph_loop"}: return "ralph" @@ -591,7 +603,9 @@ def _run_runtime_mining_loop( """Run a real RalphLoop/HelixLoop and return its factor library.""" runtime_manifest = dict(runtime_manifest or {}) loop_type = _real_mining_loop_type(baseline, runtime_manifest) - runtime_output_dir = _ensure_dir(output_dir / "benchmark" / "table1" / baseline / "runtime") + runtime_output_dir = _ensure_dir( + output_dir / "benchmark" / "table1" / baseline / "runtime" + ) runtime_cfg = _cfg_for_runtime_baseline(cfg, baseline) loop_cfg = _build_runtime_loop_config( runtime_cfg, @@ -600,7 +614,7 @@ def _run_runtime_mining_loop( mock=mock or bool(runtime_manifest.get("mock", False)), runtime_manifest=runtime_manifest, ) - provider = _build_runtime_provider(runtime_cfg, mock=mock or bool(runtime_manifest.get("mock", False))) + provider = _build_runtime_provider(runtime_cfg) if loop_type == "helix": from src.factorminer.core.helix_loop import HelixLoop @@ -624,19 +638,29 @@ def _run_runtime_mining_loop( llm_provider=provider, ) - checkpoint_interval = int(runtime_manifest.get("checkpoint_interval", 0 if mock else 1)) + checkpoint_interval = int( + runtime_manifest.get("checkpoint_interval", 0 if mock else 1) + ) loop.checkpoint_interval = checkpoint_interval if runtime_manifest.get("checkpoint_path"): loop.load_session(str(runtime_manifest["checkpoint_path"])) - target_size = int(runtime_manifest.get("target_library_size", loop_cfg.target_library_size)) - max_iterations = int(runtime_manifest.get("max_iterations", loop_cfg.max_iterations)) + target_size = int( + runtime_manifest.get("target_library_size", loop_cfg.target_library_size) + ) + max_iterations = int( + runtime_manifest.get("max_iterations", loop_cfg.max_iterations) + ) library = loop.run(target_size=target_size, max_iterations=max_iterations) provenance = _runtime_loop_provenance( baseline=baseline, loop_type=loop_type, - runtime_manifest={**runtime_manifest, "target_library_size": target_size, "max_iterations": max_iterations}, + runtime_manifest={ + **runtime_manifest, + "target_library_size": target_size, + "max_iterations": max_iterations, + }, runtime_output_dir=runtime_output_dir, ) return { @@ -728,11 +752,19 @@ def _get_baseline_entries( return dedupe_entries(build_alphaagent_style()) if baseline == "factor_miner": if factor_miner_library_path: - return dedupe_entries(entries_from_library(load_library(_base_path(factor_miner_library_path)))) + return dedupe_entries( + entries_from_library( + load_library(_base_path(factor_miner_library_path)) + ) + ) return dedupe_entries(build_factor_miner_catalog()) if baseline == "factor_miner_no_memory": if factor_miner_no_memory_library_path: - return dedupe_entries(entries_from_library(load_library(_base_path(factor_miner_no_memory_library_path)))) + return dedupe_entries( + entries_from_library( + load_library(_base_path(factor_miner_no_memory_library_path)) + ) + ) return dedupe_entries(build_random_exploration(seed + 101, count=200)) raise KeyError(f"Unknown benchmark baseline: {baseline}") @@ -899,7 +931,9 @@ def _weighted_composite( factor_signals: dict[int, np.ndarray], weights: dict[int, float], ) -> np.ndarray: - ordered = [(fid, factor_signals[fid], weights.get(fid, 0.0)) for fid in factor_signals] + ordered = [ + (fid, factor_signals[fid], weights.get(fid, 0.0)) for fid in factor_signals + ] if not ordered: raise ValueError("Cannot build weighted composite from zero factors") total = sum(abs(weight) for _, _, weight in ordered) @@ -947,18 +981,40 @@ def evaluate_frozen_set( "warnings": [], } if not succeeded: - result["warnings"].append("No frozen factors recomputed successfully on this universe") + result["warnings"].append( + "No frozen factors recomputed successfully on this universe" + ) return result result["library"] = { - "ic": float(np.mean([artifact.split_stats[split_name]["ic_abs_mean"] for artifact in succeeded])), - "icir": float(np.mean([abs(artifact.split_stats[split_name]["icir"]) for artifact in succeeded])), + "ic": float( + np.mean( + [ + artifact.split_stats[split_name]["ic_abs_mean"] + for artifact in succeeded + ] + ) + ), + "icir": float( + np.mean( + [ + abs(artifact.split_stats[split_name]["icir"]) + for artifact in succeeded + ] + ) + ), "avg_abs_rho": _avg_abs_rho(succeeded, split_name), } artifact_map = {artifact.factor_id: artifact for artifact in succeeded} - fit_signals = {artifact.factor_id: artifact.split_signals[fit_split].T for artifact in succeeded} - eval_signals = {artifact.factor_id: artifact.split_signals[split_name].T for artifact in succeeded} + fit_signals = { + artifact.factor_id: artifact.split_signals[fit_split].T + for artifact in succeeded + } + eval_signals = { + artifact.factor_id: artifact.split_signals[split_name].T + for artifact in succeeded + } fit_returns = dataset.get_split(fit_split).returns.T eval_returns = dataset.get_split(split_name).returns.T @@ -1004,11 +1060,15 @@ def evaluate_frozen_set( except Exception as exc: result["warnings"].append(f"lasso unavailable: {exc}") try: - selection_specs["forward_stepwise"] = selector.forward_stepwise(fit_signals, fit_returns) + selection_specs["forward_stepwise"] = selector.forward_stepwise( + fit_signals, fit_returns + ) except Exception as exc: result["warnings"].append(f"forward_stepwise unavailable: {exc}") try: - selection_specs["xgboost"] = selector.xgboost_selection(fit_signals, fit_returns) + selection_specs["xgboost"] = selector.xgboost_selection( + fit_signals, fit_returns + ) except Exception as exc: result["warnings"].append(f"xgboost unavailable: {exc}") @@ -1017,19 +1077,26 @@ def evaluate_frozen_set( result["selections"][name] = {"factor_count": 0} continue selected_ids = [factor_id for factor_id, _ in ranking] - selected_eval = {factor_id: eval_signals[factor_id] for factor_id in selected_ids} + selected_eval = { + factor_id: eval_signals[factor_id] for factor_id in selected_ids + } if name == "lasso": weights = {factor_id: score for factor_id, score in ranking} composite = _weighted_composite(selected_eval, weights) elif name == "xgboost": weights = { - factor_id: score * np.sign(artifact_map[factor_id].split_stats[fit_split]["ic_mean"] or 1.0) + factor_id: score + * np.sign( + artifact_map[factor_id].split_stats[fit_split]["ic_mean"] or 1.0 + ) for factor_id, score in ranking } composite = _weighted_composite(selected_eval, weights) else: signs = { - factor_id: np.sign(artifact_map[factor_id].split_stats[fit_split]["ic_mean"] or 1.0) + factor_id: np.sign( + artifact_map[factor_id].split_stats[fit_split]["ic_mean"] or 1.0 + ) for factor_id in selected_ids } composite = _weighted_composite(selected_eval, signs) @@ -1100,7 +1167,8 @@ def run_table1_benchmark( runtime_manifest = _runtime_manifest_value(runtime_manifests, baseline) runtime_baseline = bool(runtime_manifest) or ( use_runtime_loops - and baseline in (RUNTIME_LOOP_BASELINES | {"factor_miner", "factor_miner_no_memory"}) + and baseline + in (RUNTIME_LOOP_BASELINES | {"factor_miner", "factor_miner_no_memory"}) ) if runtime_baseline: @@ -1262,10 +1330,13 @@ def run_ablation_memory_benchmark( result[baseline] = { "library_size": payload["freeze_library_size"], "high_quality_yield": freeze_stats.get("admitted", 0) / succeeded, - "redundancy_rejection_rate": freeze_stats.get("correlation_rejections", 0) / succeeded, + "redundancy_rejection_rate": freeze_stats.get("correlation_rejections", 0) + / succeeded, "replacements": freeze_stats.get("replaced", 0), } - out_path = _ensure_dir(output_dir / "benchmark" / "ablation") / "memory_ablation.json" + out_path = ( + _ensure_dir(output_dir / "benchmark" / "ablation") / "memory_ablation.json" + ) _write_json(out_path, result) return result @@ -1305,7 +1376,9 @@ def run_cost_pressure_benchmark( } for universe, universe_payload in payload["universes"].items() } - out_path = _ensure_dir(output_dir / "benchmark" / "cost_pressure") / f"{baseline}.json" + out_path = ( + _ensure_dir(output_dir / "benchmark" / "cost_pressure") / f"{baseline}.json" + ) _write_json(out_path, result) return result @@ -1322,27 +1395,55 @@ def _time_callable(fn, repeats: int = 3) -> float: def run_efficiency_benchmark(cfg, output_dir: Path) -> dict: """Benchmark operator-level and factor-level compute time.""" periods, assets = cfg.benchmark.efficiency_panel_shape - matrix = np.random.RandomState(cfg.benchmark.seed).randn(assets, periods).astype(np.float64) - other = np.random.RandomState(cfg.benchmark.seed + 1).randn(assets, periods).astype(np.float64) + matrix = ( + np.random.RandomState(cfg.benchmark.seed) + .randn(assets, periods) + .astype(np.float64) + ) + other = ( + np.random.RandomState(cfg.benchmark.seed + 1) + .randn(assets, periods) + .astype(np.float64) + ) from src.factorminer.operators import torch_available from src.factorminer.operators.gpu_backend import to_tensor from src.factorminer.operators.registry import execute_operator from src.factorminer.utils.visualization import plot_efficiency_benchmark - operator_bench: dict[str, dict[str, float | None]] = {"numpy": {}, "c": {}, "gpu": {}} + operator_bench: dict[str, dict[str, float | None]] = { + "numpy": {}, + "c": {}, + "gpu": {}, + } + def _backend_inputs(backend: str): if backend == "gpu": return to_tensor(matrix), to_tensor(other) return matrix, other operators = { - "Add": lambda backend: execute_operator("Add", *_backend_inputs(backend), backend=backend), - "Mean": lambda backend: execute_operator("Mean", _backend_inputs(backend)[0], params={"window": 20}, backend=backend), - "Delta": lambda backend: execute_operator("Delta", _backend_inputs(backend)[0], params={"window": 5}, backend=backend), - "TsRank": lambda backend: execute_operator("TsRank", _backend_inputs(backend)[0], params={"window": 20}, backend=backend), - "Corr": lambda backend: execute_operator("Corr", *_backend_inputs(backend), params={"window": 20}, backend=backend), - "CsRank": lambda backend: execute_operator("CsRank", _backend_inputs(backend)[0], backend=backend), + "Add": lambda backend: execute_operator( + "Add", *_backend_inputs(backend), backend=backend + ), + "Mean": lambda backend: execute_operator( + "Mean", _backend_inputs(backend)[0], params={"window": 20}, backend=backend + ), + "Delta": lambda backend: execute_operator( + "Delta", _backend_inputs(backend)[0], params={"window": 5}, backend=backend + ), + "TsRank": lambda backend: execute_operator( + "TsRank", + _backend_inputs(backend)[0], + params={"window": 20}, + backend=backend, + ), + "Corr": lambda backend: execute_operator( + "Corr", *_backend_inputs(backend), params={"window": 20}, backend=backend + ), + "CsRank": lambda backend: execute_operator( + "CsRank", _backend_inputs(backend)[0], backend=backend + ), } for op_name, runner in operators.items(): operator_bench["numpy"][op_name] = _time_callable(lambda r=runner: r("numpy")) @@ -1358,11 +1459,21 @@ def run_efficiency_benchmark(cfg, output_dir: Path) -> dict: "CsRank", execute_operator( "Mul", - execute_operator("Return", _backend_inputs(backend)[0], params={"window": 5}, backend=backend), + execute_operator( + "Return", + _backend_inputs(backend)[0], + params={"window": 5}, + backend=backend, + ), execute_operator( "Div", _backend_inputs(backend)[1], - execute_operator("Mean", _backend_inputs(backend)[1], params={"window": 20}, backend=backend), + execute_operator( + "Mean", + _backend_inputs(backend)[1], + params={"window": 20}, + backend=backend, + ), backend=backend, ), backend=backend, @@ -1379,7 +1490,9 @@ def run_efficiency_benchmark(cfg, output_dir: Path) -> dict: execute_operator( "Add", _backend_inputs(backend)[1], - to_tensor(np.full_like(other, 1e-8)) if backend == "gpu" else np.full_like(other, 1e-8), + to_tensor(np.full_like(other, 1e-8)) + if backend == "gpu" + else np.full_like(other, 1e-8), backend=backend, ), backend=backend, @@ -1390,20 +1503,30 @@ def run_efficiency_benchmark(cfg, output_dir: Path) -> dict: ), } for formula_name, runner in factor_specs.items(): - factor_bench["numpy"][formula_name] = _time_callable(lambda r=runner: r("numpy")) + factor_bench["numpy"][formula_name] = _time_callable( + lambda r=runner: r("numpy") + ) factor_bench["c"][formula_name] = None if torch_available(): - factor_bench["gpu"][formula_name] = _time_callable(lambda r=runner: r("gpu")) + factor_bench["gpu"][formula_name] = _time_callable( + lambda r=runner: r("gpu") + ) else: factor_bench["gpu"][formula_name] = None bench_dir = _ensure_dir(output_dir / "benchmark" / "efficiency") plot_efficiency_benchmark( - {backend: {k: v for k, v in values.items() if v is not None} for backend, values in operator_bench.items()}, + { + backend: {k: v for k, v in values.items() if v is not None} + for backend, values in operator_bench.items() + }, save_path=str(bench_dir / "operator_efficiency.png"), ) plot_efficiency_benchmark( - {backend: {k: v for k, v in values.items() if v is not None} for backend, values in factor_bench.items()}, + { + backend: {k: v for k, v in values.items() if v is not None} + for backend, values in factor_bench.items() + }, save_path=str(bench_dir / "factor_efficiency.png"), ) result = { diff --git a/src/factorminer/cli.py b/src/factorminer/cli.py index d72c903..f7ece71 100644 --- a/src/factorminer/cli.py +++ b/src/factorminer/cli.py @@ -20,6 +20,7 @@ logger = logging.getLogger(__name__) # Helpers # --------------------------------------------------------------------------- + def _setup_logging(verbose: bool) -> None: """Configure root logger for CLI output.""" level = logging.DEBUG if verbose else logging.INFO @@ -42,18 +43,6 @@ def _load_data(cfg, data_path: str | None, mock: bool): raw_cfg = getattr(cfg, "_raw", {}) configured_path = raw_cfg.get("data_path") - if mock: - click.echo("Generating mock market data...") - from src.factorminer.data.mock_data import MockConfig, generate_mock_data - - mock_cfg = MockConfig( - num_assets=50, - num_periods=500, - frequency="1d", - plant_alpha=True, - ) - return generate_mock_data(mock_cfg) - # Try data_path argument, then config top-level data_path path = data_path if path is None: @@ -163,13 +152,9 @@ def _prepare_data_arrays(df): return data_tensor, returns -def _create_llm_provider(cfg, mock: bool): - """Create an LLM provider from config or use mock.""" - from src.factorminer.agent.llm_interface import MockProvider, create_provider - - if mock: - click.echo("Using mock LLM provider (no API calls).") - return MockProvider() +def _create_llm_provider(cfg): + """Create an LLM provider from config.""" + from src.factorminer.agent.llm_interface import create_provider llm_config = { "provider": cfg.llm.provider, @@ -183,13 +168,11 @@ def _create_llm_provider(cfg, mock: bool): return create_provider(llm_config) -def _build_core_mining_config(cfg, output_dir: Path, mock: bool = False): +def _build_core_mining_config(cfg, output_dir: Path): """Create the flat mining config expected by RalphLoop/HelixLoop.""" from src.factorminer.core.config import MiningConfig as CoreMiningConfig - signal_failure_policy = ( - "synthetic" if mock else cfg.evaluation.signal_failure_policy - ) + signal_failure_policy = cfg.evaluation.signal_failure_policy mining_cfg = CoreMiningConfig( target_library_size=cfg.mining.target_library_size, @@ -239,9 +222,7 @@ def _filter_dataclass_kwargs(source, target_cls): target_fields = {f.name for f in fields(target_cls)} source_fields = getattr(source, "__dataclass_fields__", {}) return { - name: getattr(source, name) - for name in source_fields - if name in target_fields + name: getattr(source, name) for name in source_fields if name in target_fields } @@ -275,7 +256,9 @@ def _build_debate_config(cfg): def _build_phase2_runtime_configs(cfg): """Instantiate evaluation/runtime configs for the Helix loop.""" from src.factorminer.evaluation.causal import CausalConfig as RuntimeCausalConfig - from src.factorminer.evaluation.capacity import CapacityConfig as RuntimeCapacityConfig + from src.factorminer.evaluation.capacity import ( + CapacityConfig as RuntimeCapacityConfig, + ) from src.factorminer.evaluation.regime import RegimeConfig as RuntimeRegimeConfig from src.factorminer.evaluation.significance import ( SignificanceConfig as RuntimeSignificanceConfig, @@ -302,7 +285,9 @@ def _build_phase2_runtime_configs(cfg): significance_config = None if cfg.phase2.significance.enabled: significance_config = RuntimeSignificanceConfig( - **_filter_dataclass_kwargs(cfg.phase2.significance, RuntimeSignificanceConfig) + **_filter_dataclass_kwargs( + cfg.phase2.significance, RuntimeSignificanceConfig + ) ) return { @@ -430,7 +415,9 @@ def _select_artifacts_for_ids(artifacts, factor_ids: tuple[int, ...]): return selected -def _analysis_output_path(output_dir: Path, stem: str, split_name: str, fmt: str) -> str: +def _analysis_output_path( + output_dir: Path, stem: str, split_name: str, fmt: str +) -> str: return str(output_dir / f"{stem}_{split_name}.{fmt}") @@ -443,7 +430,9 @@ def _print_benchmark_summary(title: str, payload: dict) -> None: click.echo("No benchmark results produced.") return - if all(isinstance(value, dict) and "universes" in value for value in payload.values()): + if all( + isinstance(value, dict) and "universes" in value for value in payload.values() + ): for baseline, result in payload.items(): click.echo(f"Baseline: {baseline}") click.echo( @@ -484,7 +473,9 @@ def _print_split_summary(artifacts, split_name: str) -> None: return ic_values = [artifact.split_stats[split_name]["ic_mean"] for artifact in artifacts] - abs_ic_values = [artifact.split_stats[split_name]["ic_abs_mean"] for artifact in artifacts] + abs_ic_values = [ + artifact.split_stats[split_name]["ic_abs_mean"] for artifact in artifacts + ] icir_values = [artifact.split_stats[split_name]["icir"] for artifact in artifacts] click.echo("-" * 90) click.echo(f" Total factors: {len(artifacts)}") @@ -529,24 +520,31 @@ def _load_library_from_path(library_path: str): # Global options # --------------------------------------------------------------------------- + @click.group() @click.option( - "--config", "-c", + "--config", + "-c", type=click.Path(exists=True, dir_okay=False), default=None, help="Path to a YAML config file (merges with defaults).", ) -@click.option("--gpu/--cpu", default=True, help="Enable or disable GPU evaluation backend.") +@click.option( + "--gpu/--cpu", default=True, help="Enable or disable GPU evaluation backend." +) @click.option("--verbose", "-v", is_flag=True, help="Enable debug-level logging.") @click.option( - "--output-dir", "-o", + "--output-dir", + "-o", type=click.Path(file_okay=False), default="output", help="Directory for all output artifacts.", ) @click.version_option(package_name="src.factorminer") @click.pass_context -def main(ctx: click.Context, config: str | None, gpu: bool, verbose: bool, output_dir: str) -> None: +def main( + ctx: click.Context, config: str | None, gpu: bool, verbose: bool, output_dir: str +) -> None: """FactorMiner -- LLM-powered quantitative factor mining.""" _setup_logging(verbose) @@ -555,7 +553,9 @@ def main(ctx: click.Context, config: str | None, gpu: bool, verbose: bool, outpu overrides.setdefault("evaluation", {})["backend"] = "numpy" try: - cfg = load_config(config_path=config, overrides=overrides if overrides else None) + cfg = load_config( + config_path=config, overrides=overrides if overrides else None + ) except Exception as e: click.echo(f"Error loading config: {e}") raise click.Abort() @@ -564,6 +564,7 @@ def main(ctx: click.Context, config: str | None, gpu: bool, verbose: bool, outpu try: import yaml from src.factorminer.configs import DEFAULT_CONFIG_PATH + raw = {} if DEFAULT_CONFIG_PATH.exists(): with open(DEFAULT_CONFIG_PATH) as f: @@ -589,13 +590,31 @@ def main(ctx: click.Context, config: str | None, gpu: bool, verbose: bool, outpu # mine # --------------------------------------------------------------------------- + @main.command() -@click.option("--iterations", "-n", type=int, default=None, help="Override max_iterations.") +@click.option( + "--iterations", "-n", type=int, default=None, help="Override max_iterations." +) @click.option("--batch-size", "-b", type=int, default=None, help="Override batch_size.") -@click.option("--target", "-t", type=int, default=None, help="Override target_library_size.") -@click.option("--resume", type=click.Path(exists=True), default=None, help="Resume from a saved library.") -@click.option("--mock", is_flag=True, help="Use mock data and mock LLM provider (for testing).") -@click.option("--data", "data_path", type=click.Path(exists=True), default=None, help="Path to market data file.") +@click.option( + "--target", "-t", type=int, default=None, help="Override target_library_size." +) +@click.option( + "--resume", + type=click.Path(exists=True), + default=None, + help="Resume from a saved library.", +) +@click.option( + "--mock", is_flag=True, help="Use mock data and mock LLM provider (for testing)." +) +@click.option( + "--data", + "data_path", + type=click.Path(exists=True), + default=None, + help="Path to market data file.", +) @click.pass_context def mine( ctx: click.Context, @@ -650,7 +669,7 @@ def mine( returns = dataset.returns # Create LLM provider - llm_provider = _create_llm_provider(cfg, mock) + llm_provider = _create_llm_provider(cfg) # Load existing library for resume library = None @@ -659,7 +678,7 @@ def mine( library = _load_library_from_path(resume) # Create and configure MiningConfig for the RalphLoop - mining_config = _build_core_mining_config(cfg, output_dir, mock=mock) + mining_config = _build_core_mining_config(cfg, output_dir) _attach_runtime_targets(mining_config, dataset) # Create and run the Ralph Loop @@ -710,12 +729,26 @@ def mine( # evaluate # --------------------------------------------------------------------------- + @main.command() @click.argument("library_path", type=click.Path(exists=True)) -@click.option("--data", "data_path", type=click.Path(exists=True), default=None, help="Path to market data file.") +@click.option( + "--data", + "data_path", + type=click.Path(exists=True), + default=None, + help="Path to market data file.", +) @click.option("--mock", is_flag=True, help="Use mock data for evaluation.") -@click.option("--period", type=click.Choice(["train", "test", "both"]), default="test", help="Evaluation period.") -@click.option("--top-k", type=int, default=None, help="Evaluate only the top-K factors by IC.") +@click.option( + "--period", + type=click.Choice(["train", "test", "both"]), + default="test", + help="Evaluation period.", +) +@click.option( + "--top-k", type=int, default=None, help="Evaluate only the top-K factors by IC." +) @click.pass_context def evaluate( ctx: click.Context, @@ -763,7 +796,9 @@ def evaluate( if top_k is not None and top_k < len([a for a in artifacts if a.succeeded]): if period == "both": - click.echo(f" Evaluating top {top_k} factors by train |IC| for train/test comparison") + click.echo( + f" Evaluating top {top_k} factors by train |IC| for train/test comparison" + ) else: click.echo(f" Evaluating top {top_k} factors by {selection_split} |IC|") @@ -776,7 +811,9 @@ def evaluate( if period == "both" and selected: click.echo("-" * 60) click.echo("Decay summary (train -> test)") - click.echo(f"{'ID':>4s} {'Name':<35s} {'Train |IC|':>10s} {'Test |IC|':>9s} {'Delta':>8s}") + click.echo( + f"{'ID':>4s} {'Name':<35s} {'Train |IC|':>10s} {'Test |IC|':>9s} {'Delta':>8s}" + ) click.echo("-" * 80) for artifact in selected: train_ic = artifact.split_stats["train"]["ic_abs_mean"] @@ -793,9 +830,16 @@ def evaluate( # combine # --------------------------------------------------------------------------- + @main.command() @click.argument("library_path", type=click.Path(exists=True)) -@click.option("--data", "data_path", type=click.Path(exists=True), default=None, help="Path to market data file.") +@click.option( + "--data", + "data_path", + type=click.Path(exists=True), + default=None, + help="Path to market data file.", +) @click.option("--mock", is_flag=True, help="Use mock data for combination.") @click.option( "--fit-period", @@ -810,18 +854,22 @@ def evaluate( help="Split used to evaluate the combined signal.", ) @click.option( - "--method", "-m", + "--method", + "-m", type=click.Choice(["equal-weight", "ic-weighted", "orthogonal", "all"]), default="all", help="Factor combination method.", ) @click.option( - "--selection", "-s", + "--selection", + "-s", type=click.Choice(["lasso", "stepwise", "xgboost", "none"]), default="none", help="Factor selection method to run before combination.", ) -@click.option("--top-k", type=int, default=None, help="Select top-K factors before combining.") +@click.option( + "--top-k", type=int, default=None, help="Select top-K factors before combining." +) @click.pass_context def combine( ctx: click.Context, @@ -874,7 +922,9 @@ def combine( raise click.Abort() if top_k is not None and top_k < len([a for a in artifacts if a.succeeded]): - click.echo(f" Pre-selected top {len(selected_artifacts)} factors by {fit_split} |IC|") + click.echo( + f" Pre-selected top {len(selected_artifacts)} factors by {fit_split} |IC|" + ) click.echo(f" Fit split: {fit_split}") click.echo(f" Eval split: {eval_split}") @@ -916,7 +966,9 @@ def combine( else: click.echo(f" {selection} selection returned no factors.") except ImportError as e: - click.echo(f" Selection method '{selection}' requires additional packages: {e}") + click.echo( + f" Selection method '{selection}' requires additional packages: {e}" + ) except Exception as e: click.echo(f" Selection error: {e}") logger.exception("Selection failed") @@ -981,7 +1033,9 @@ def combine( research_path.write_text(json.dumps(research_reports, indent=2)) for model_name, report in research_reports.items(): if not report.get("available", True): - click.echo(f" {model_name}: unavailable ({report.get('error', 'unknown error')})") + click.echo( + f" {model_name}: unavailable ({report.get('error', 'unknown error')})" + ) continue click.echo( f" {model_name}: " @@ -1001,18 +1055,47 @@ def combine( # visualize # --------------------------------------------------------------------------- + @main.command() @click.argument("library_path", type=click.Path(exists=True)) -@click.option("--data", "data_path", type=click.Path(exists=True), default=None, help="Path to market data file.") +@click.option( + "--data", + "data_path", + type=click.Path(exists=True), + default=None, + help="Path to market data file.", +) @click.option("--mock", is_flag=True, help="Use mock data for visualization.") -@click.option("--period", type=click.Choice(["train", "test", "both"]), default="test", help="Evaluation split to visualize.") -@click.option("--factor-id", "factor_ids", type=int, multiple=True, help="Specific factor ID(s) to visualize.") -@click.option("--top-k", type=int, default=None, help="Top-K factors by split |IC| for set-level plots.") +@click.option( + "--period", + type=click.Choice(["train", "test", "both"]), + default="test", + help="Evaluation split to visualize.", +) +@click.option( + "--factor-id", + "factor_ids", + type=int, + multiple=True, + help="Specific factor ID(s) to visualize.", +) +@click.option( + "--top-k", + type=int, + default=None, + help="Top-K factors by split |IC| for set-level plots.", +) @click.option("--tearsheet", is_flag=True, help="Generate a full factor tear sheet.") @click.option("--correlation", is_flag=True, help="Plot factor correlation heatmap.") @click.option("--ic-timeseries", is_flag=True, help="Plot IC time series.") @click.option("--quintile", is_flag=True, help="Plot quintile returns.") -@click.option("--format", "fmt", type=click.Choice(["png", "pdf", "svg"]), default="png", help="Output format.") +@click.option( + "--format", + "fmt", + type=click.Choice(["png", "pdf", "svg"]), + default="png", + help="Output format.", +) @click.pass_context def visualize( ctx: click.Context, @@ -1098,7 +1181,9 @@ def visualize( if corr_artifacts: click.echo(" Generating correlation heatmap...") corr_matrix = compute_correlation_matrix(corr_artifacts, split_name) - save_path = _analysis_output_path(output_dir, "correlation_heatmap", split_name, fmt) + save_path = _analysis_output_path( + output_dir, "correlation_heatmap", split_name, fmt + ) plot_correlation_heatmap( corr_matrix, [artifact.name[:20] for artifact in corr_artifacts], @@ -1107,7 +1192,9 @@ def visualize( ) click.echo(f" Saved: {save_path}") else: - click.echo(" Skipped: no successfully recomputed factors for correlation heatmap.") + click.echo( + " Skipped: no successfully recomputed factors for correlation heatmap." + ) factor_artifacts = explicit_artifacts if not factor_ids and (ic_timeseries or quintile or tearsheet): @@ -1148,9 +1235,7 @@ def visualize( fmt, ) plot_quintile_returns( - { - f"Q{i}": stats[f"Q{i}"] for i in range(1, 6) - } + {f"Q{i}": stats[f"Q{i}"] for i in range(1, 6)} | { "long_short": stats["long_short"], "monotonicity": stats["monotonicity"], @@ -1190,17 +1275,23 @@ def visualize( # export # --------------------------------------------------------------------------- + @main.command(name="export") @click.argument("library_path", type=click.Path(exists=True)) @click.option( - "--format", "fmt", + "--format", + "fmt", type=click.Choice(["json", "csv", "formulas"]), default="json", help="Export format.", ) -@click.option("--output", "-o", type=click.Path(), default=None, help="Output file path.") +@click.option( + "--output", "-o", type=click.Path(), default=None, help="Output file path." +) @click.pass_context -def export_cmd(ctx: click.Context, library_path: str, fmt: str, output: str | None) -> None: +def export_cmd( + ctx: click.Context, library_path: str, fmt: str, output: str | None +) -> None: """Export a factor library to various formats.""" output_dir = ctx.obj["output_dir"] @@ -1224,7 +1315,11 @@ def export_cmd(ctx: click.Context, library_path: str, fmt: str, output: str | No click.echo("-" * 60) try: - from src.factorminer.core.library_io import export_csv, export_formulas, save_library + from src.factorminer.core.library_io import ( + export_csv, + export_formulas, + save_library, + ) if fmt == "json": # save_library expects base path without extension @@ -1256,6 +1351,7 @@ def export_cmd(ctx: click.Context, library_path: str, fmt: str, output: str | No # benchmark # --------------------------------------------------------------------------- + @main.group() def benchmark() -> None: """Run strict paper/research benchmark workflows.""" @@ -1290,7 +1386,12 @@ def _benchmark_common_options(fn): @benchmark.command("table1") -@click.option("--baseline", "baselines", multiple=True, help="Restrict to one or more baseline ids.") +@click.option( + "--baseline", + "baselines", + multiple=True, + help="Restrict to one or more baseline ids.", +) @_benchmark_common_options def benchmark_table1( ctx: click.Context, @@ -1410,17 +1511,49 @@ def benchmark_suite( # helix # --------------------------------------------------------------------------- + @main.command() -@click.option("--iterations", "-n", type=int, default=None, help="Override max_iterations.") +@click.option( + "--iterations", "-n", type=int, default=None, help="Override max_iterations." +) @click.option("--batch-size", "-b", type=int, default=None, help="Override batch_size.") -@click.option("--target", "-t", type=int, default=None, help="Override target_library_size.") -@click.option("--resume", type=click.Path(exists=True), default=None, help="Resume from a saved library.") -@click.option("--causal/--no-causal", default=None, help="Enable/disable causal validation.") -@click.option("--regime/--no-regime", default=None, help="Enable/disable regime-conditional evaluation.") -@click.option("--debate/--no-debate", default=None, help="Enable/disable multi-specialist debate generation.") -@click.option("--canonicalize/--no-canonicalize", default=None, help="Enable/disable SymPy canonicalization.") -@click.option("--mock", is_flag=True, help="Use mock data and mock LLM provider (for testing).") -@click.option("--data", "data_path", type=click.Path(exists=True), default=None, help="Path to market data file.") +@click.option( + "--target", "-t", type=int, default=None, help="Override target_library_size." +) +@click.option( + "--resume", + type=click.Path(exists=True), + default=None, + help="Resume from a saved library.", +) +@click.option( + "--causal/--no-causal", default=None, help="Enable/disable causal validation." +) +@click.option( + "--regime/--no-regime", + default=None, + help="Enable/disable regime-conditional evaluation.", +) +@click.option( + "--debate/--no-debate", + default=None, + help="Enable/disable multi-specialist debate generation.", +) +@click.option( + "--canonicalize/--no-canonicalize", + default=None, + help="Enable/disable SymPy canonicalization.", +) +@click.option( + "--mock", is_flag=True, help="Use mock data and mock LLM provider (for testing)." +) +@click.option( + "--data", + "data_path", + type=click.Path(exists=True), + default=None, + help="Path to market data file.", +) @click.pass_context def helix( ctx: click.Context, @@ -1466,15 +1599,19 @@ def helix( enabled_features = _active_phase2_features(cfg) click.echo("HelixFactor Phase 2 mining engine.") - click.echo(f" Target: {cfg.mining.target_library_size} | " - f"Batch: {cfg.mining.batch_size} | " - f"Max iterations: {cfg.mining.max_iterations}") + click.echo( + f" Target: {cfg.mining.target_library_size} | " + f"Batch: {cfg.mining.batch_size} | " + f"Max iterations: {cfg.mining.max_iterations}" + ) click.echo(f" Output directory: {output_dir}") if enabled_features: click.echo(f" Active Phase 2 features: {', '.join(enabled_features)}") else: - click.echo(" No Phase 2 features enabled. Configure phase2.* in your config to enable features.") + click.echo( + " No Phase 2 features enabled. Configure phase2.* in your config to enable features." + ) if resume: click.echo(f" Resuming from: {resume}") @@ -1488,16 +1625,18 @@ def helix( click.echo(" Preparing data tensors...") data_tensor = dataset.data_tensor returns = dataset.returns - llm_provider = _create_llm_provider(cfg, mock) + llm_provider = _create_llm_provider(cfg) library = None if resume: library = _load_library_from_path(resume) - mining_config = _build_core_mining_config(cfg, output_dir, mock=mock) + mining_config = _build_core_mining_config(cfg, output_dir) _attach_runtime_targets(mining_config, dataset) phase2_configs = _build_phase2_runtime_configs(cfg) - volume = _extract_capacity_volume(data_tensor) if cfg.phase2.capacity.enabled else None + volume = ( + _extract_capacity_volume(data_tensor) if cfg.phase2.capacity.enabled else None + ) from src.factorminer.core.helix_loop import HelixLoop diff --git a/src/factorminer/core/factor_library.py b/src/factorminer/core/factor_library.py index 289fef5..5549d5d 100644 --- a/src/factorminer/core/factor_library.py +++ b/src/factorminer/core/factor_library.py @@ -4,8 +4,10 @@ Implements the admission rules from the paper (Eq. 10, 11): - Admission: IC(alpha) >= tau_IC AND max_{g in L} |rho(alpha, g)| < theta - Replacement: IC(alpha) >= 0.10 AND IC(alpha) >= 1.3 * IC(g) AND only 1 correlated factor -The library tracks pairwise Spearman correlations and supports incremental -updates as new factors are added or replaced. +内存优化策略:库中因子不再长期持有 (M,T) numpy signals。 +相关性检查改为按需调用 LocalFactorEvaluator 重算。 + +本地引擎重算成本低,内存优先。 """ from __future__ import annotations @@ -14,11 +16,14 @@ import logging from collections import defaultdict from dataclasses import dataclass, field from datetime import datetime -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING import numpy as np from scipy.stats import spearmanr +if TYPE_CHECKING: + from src.factorminer.evaluation.local_engine import LocalFactorEvaluator + logger = logging.getLogger(__name__) @@ -86,6 +91,8 @@ class Factor: class FactorLibrary: """The factor library L that maintains admitted alpha factors. + 内存优化:因子不再持有 signals,按需通过 LocalFactorEvaluator 重算。 + Parameters ---------- correlation_threshold : float @@ -100,11 +107,11 @@ class FactorLibrary: ic_threshold: float = 0.04, ) -> None: self.factors: Dict[int, Factor] = {} - self.correlation_matrix: Optional[np.ndarray] = None # Pairwise |rho| self._next_id: int = 1 self.correlation_threshold = correlation_threshold self.ic_threshold = ic_threshold - # Maps factor_id -> index in the correlation matrix + # 内部状态:仅用于诊断(按需计算,不持久化) + self._correlation_cache: Optional[np.ndarray] = None self._id_to_index: Dict[int, int] = {} # ------------------------------------------------------------------ @@ -190,19 +197,26 @@ class FactorLibrary: # ------------------------------------------------------------------ def check_admission( - self, candidate_ic: float, candidate_signals: np.ndarray + self, + candidate_ic: float, + candidate_signals: np.ndarray, + evaluator: Optional["LocalFactorEvaluator"] = None, ) -> Tuple[bool, str]: """Check if candidate passes admission criteria (Eq. 10). Admission rule: IC(alpha) >= tau_IC AND max_{g in L} |rho(alpha, g)| < theta + 内存优化:当提供 evaluator 时,按需重算库中因子信号进行比较。 + Parameters ---------- candidate_ic : float The candidate factor's mean IC. candidate_signals : np.ndarray, shape (M, T) The candidate's realized signals. + evaluator : LocalFactorEvaluator, optional + 本地因子评估器,用于按需重算库中因子信号。 Returns ------- @@ -214,7 +228,7 @@ class FactorLibrary: if self.size == 0: return True, "First factor in library" - max_corr = self._max_correlation_with_library(candidate_signals) + max_corr = self._max_correlation_with_library(candidate_signals, evaluator) if max_corr >= self.correlation_threshold: return False, ( @@ -228,6 +242,7 @@ class FactorLibrary: self, candidate_ic: float, candidate_signals: np.ndarray, + evaluator: Optional["LocalFactorEvaluator"] = None, ic_min: float = 0.10, ic_ratio: float = 1.3, ) -> Tuple[bool, Optional[int], str]: @@ -241,12 +256,16 @@ class FactorLibrary: If exactly one library factor g is correlated above theta AND the candidate's IC dominates g's IC by the required ratio, replace g. + 内存优化:当提供 evaluator 时,按需重算库中因子信号。 + Parameters ---------- candidate_ic : float The candidate's mean IC. candidate_signals : np.ndarray, shape (M, T) The candidate's realized signals. + evaluator : LocalFactorEvaluator, optional + 本地因子评估器,用于按需重算库中因子信号。 ic_min : float Absolute IC floor for replacement (default 0.10). ic_ratio : float @@ -269,10 +288,12 @@ class FactorLibrary: # Find all library factors correlated above theta correlated_factors = [] for fid, factor in self.factors.items(): - if factor.signals is None: + # 按需重算因子信号 + factor_signals = self._get_factor_signals(factor, evaluator) + if factor_signals is None: continue corr = self._compute_correlation_vectorized( - candidate_signals, factor.signals + candidate_signals, factor_signals ) if corr >= self.correlation_threshold: correlated_factors.append((fid, corr, factor.ic_mean)) @@ -312,13 +333,15 @@ class FactorLibrary: # ------------------------------------------------------------------ def admit_factor(self, factor: Factor) -> int: - """Add a factor to the library and update the correlation matrix. + """Add a factor to the library. + + 内存优化:不再保存 signals 到 Factor 对象,相关性检查改为按需重算。 Parameters ---------- factor : Factor The factor to add. Its ``id`` field is overwritten with the - next available ID. + next available ID. signals 字段将被忽略。 Returns ------- @@ -327,10 +350,14 @@ class FactorLibrary: """ factor.id = self._next_id self._next_id += 1 + + # 不再持有 signals,清理以节省内存 + factor.signals = None + self.factors[factor.id] = factor - # Update correlation matrix incrementally - self._extend_correlation_matrix(factor) + # 清理相关性缓存 + self._correlation_cache = None logger.info( "Admitted factor %d '%s' (IC=%.4f, max_corr=%.4f, category=%s)", @@ -345,8 +372,7 @@ class FactorLibrary: def replace_factor(self, old_id: int, new_factor: Factor) -> None: """Replace an existing factor with a better one. - The new factor takes the old factor's position in the correlation - matrix and receives a fresh ID. + 内存优化:不再持有 signals,清理相关性缓存。 Parameters ---------- @@ -362,17 +388,17 @@ class FactorLibrary: new_factor.id = self._next_id self._next_id += 1 - # Remove old factor and reuse its matrix slot - old_index = self._id_to_index.pop(old_id) + # 不再持有 signals,清理以节省内存 + new_factor.signals = None + + # Remove old factor del self.factors[old_id] - # Insert new factor at the same index + # Insert new factor self.factors[new_factor.id] = new_factor - self._id_to_index[new_factor.id] = old_index - # Recompute the row/column for this index - if self.correlation_matrix is not None and new_factor.signals is not None: - self._recompute_matrix_slot(old_index, new_factor) + # 清理相关性缓存 + self._correlation_cache = None logger.info( "Replaced factor %d with %d '%s' (IC=%.4f)", @@ -397,111 +423,83 @@ class FactorLibrary: ) # ------------------------------------------------------------------ - # Correlation matrix management + # Correlation matrix management (按需重算) # ------------------------------------------------------------------ - def _max_correlation_with_library(self, candidate_signals: np.ndarray) -> float: - """Compute max |rho| between candidate and all library factors.""" + def _get_factor_signals( + self, + factor: Factor, + evaluator: Optional["LocalFactorEvaluator"], + ) -> Optional[np.ndarray]: + """获取因子信号。 + + 优先使用 evaluator 按需重算,否则返回 Factor 对象中缓存的 signals。 + + Args: + factor: 因子对象 + evaluator: 本地因子评估器 + + Returns: + (M, T) 信号矩阵或 None + """ + # 如果 formula 以 # TODO 开头,说明不支持,跳过 + if factor.formula.startswith("# TODO"): + return None + + if evaluator is not None: + try: + return evaluator.evaluate_single(factor.name, factor.formula) + except Exception as e: + logger.warning( + "计算因子 %s 信号失败: %s", + factor.name, + e, + ) + return None + + # 回退到缓存的 signals(已废弃,仅兼容) + return factor.signals + + def _max_correlation_with_library( + self, + candidate_signals: np.ndarray, + evaluator: Optional["LocalFactorEvaluator"] = None, + ) -> float: + """Compute max |rho| between candidate and all library factors. + + 内存优化:按需通过 evaluator 重算库中因子信号。 + + Args: + candidate_signals: 候选因子信号 (M, T) + evaluator: 本地因子评估器 + + Returns: + 最大绝对相关值 + """ max_corr = 0.0 for factor in self.factors.values(): - if factor.signals is None: + factor_signals = self._get_factor_signals(factor, evaluator) + if factor_signals is None: continue corr = self._compute_correlation_vectorized( - candidate_signals, factor.signals + candidate_signals, factor_signals ) max_corr = max(max_corr, corr) return max_corr - def _extend_correlation_matrix(self, new_factor: Factor) -> None: - """Extend the correlation matrix by one row/column for the new factor.""" - n = len(self._id_to_index) - new_index = n - self._id_to_index[new_factor.id] = new_index - - if new_factor.signals is None: - # No signals to correlate; expand with zeros - if self.correlation_matrix is None: - self.correlation_matrix = np.zeros((1, 1), dtype=np.float64) - else: - new_size = new_index + 1 - new_mat = np.zeros((new_size, new_size), dtype=np.float64) - new_mat[:new_index, :new_index] = self.correlation_matrix - self.correlation_matrix = new_mat - return - - # Build a new (n+1) x (n+1) matrix - new_size = new_index + 1 - new_mat = np.zeros((new_size, new_size), dtype=np.float64) - - if self.correlation_matrix is not None and self.correlation_matrix.size > 0: - new_mat[:new_index, :new_index] = self.correlation_matrix - - # Compute correlations with all existing factors - index_to_id = {idx: fid for fid, idx in self._id_to_index.items()} - for idx in range(new_index): - fid = index_to_id.get(idx) - if fid is None: - continue - other = self.factors.get(fid) - if other is None or other.signals is None: - continue - corr = self._compute_correlation_vectorized( - new_factor.signals, other.signals - ) - new_mat[new_index, idx] = corr - new_mat[idx, new_index] = corr - - self.correlation_matrix = new_mat - - def _recompute_matrix_slot(self, idx: int, factor: Factor) -> None: - """Recompute one row/column of the correlation matrix after replacement.""" - n = self.correlation_matrix.shape[0] - index_to_id = {i: fid for fid, i in self._id_to_index.items()} - - for other_idx in range(n): - if other_idx == idx: - self.correlation_matrix[idx, idx] = 0.0 - continue - other_fid = index_to_id.get(other_idx) - if other_fid is None: - continue - other = self.factors.get(other_fid) - if other is None or other.signals is None: - self.correlation_matrix[idx, other_idx] = 0.0 - self.correlation_matrix[other_idx, idx] = 0.0 - continue - corr = self._compute_correlation_vectorized(factor.signals, other.signals) - self.correlation_matrix[idx, other_idx] = corr - self.correlation_matrix[other_idx, idx] = corr - def update_correlation_matrix(self) -> None: - """Recompute the full pairwise correlation matrix from scratch. + """废弃方法。 - This is O(n^2) in the number of library factors and should only be - called when the incremental updates may have drifted or after bulk - operations. + 内存优化后,相关性矩阵不再持久化。如需计算相关性, + 请使用 LocalFactorEvaluator 按需重算。 + + 此方法仅清理内部缓存。 """ - ids = sorted(self.factors.keys()) - n = len(ids) - if n == 0: - self.correlation_matrix = None - self._id_to_index.clear() - return - - self._id_to_index = {fid: i for i, fid in enumerate(ids)} - mat = np.zeros((n, n), dtype=np.float64) - - factors_list = [self.factors[fid] for fid in ids] - for i in range(n): - for j in range(i + 1, n): - fi, fj = factors_list[i], factors_list[j] - if fi.signals is None or fj.signals is None: - continue - corr = self._compute_correlation_vectorized(fi.signals, fj.signals) - mat[i, j] = corr - mat[j, i] = corr - - self.correlation_matrix = mat + self._correlation_cache = None + self._id_to_index.clear() + logger.warning( + "[factor_library] update_correlation_matrix 已废弃,相关性计算改为按需重算" + ) # ------------------------------------------------------------------ # Queries and diagnostics @@ -527,18 +525,17 @@ class FactorLibrary: return [f for f in self.factors.values() if f.category == category] def get_diagnostics(self) -> dict: - """Library diagnostics: avg |rho|, max tail correlations, per-category counts, saturation. + """Library diagnostics: per-category counts and IC statistics. + + 注意:内存优化后,相关性矩阵不再持久化。 + 如需相关性统计,请使用 LocalFactorEvaluator 按需计算。 Returns ------- dict with keys: - size: int - - avg_correlation: float (average off-diagonal |rho|) - - max_correlation: float (maximum off-diagonal |rho|) - - p95_correlation: float (95th percentile off-diagonal |rho|) - category_counts: dict[str, int] - category_avg_ic: dict[str, float] - - saturation: float (fraction of max correlation slots above 0.3) """ diag: dict = {"size": self.size} @@ -554,30 +551,6 @@ class FactorLibrary: cat: cat_ic_sums[cat] / cat_counts[cat] for cat in cat_counts } - # Correlation statistics - if self.correlation_matrix is not None and self.size > 1: - n = self.correlation_matrix.shape[0] - # Extract upper triangle (off-diagonal) - triu_idx = np.triu_indices(n, k=1) - off_diag = self.correlation_matrix[triu_idx] - valid = off_diag[~np.isnan(off_diag)] - - if len(valid) > 0: - diag["avg_correlation"] = float(np.mean(valid)) - diag["max_correlation"] = float(np.max(valid)) - diag["p95_correlation"] = float(np.percentile(valid, 95)) - diag["saturation"] = float(np.mean(valid > 0.3)) - else: - diag["avg_correlation"] = 0.0 - diag["max_correlation"] = 0.0 - diag["p95_correlation"] = 0.0 - diag["saturation"] = 0.0 - else: - diag["avg_correlation"] = 0.0 - diag["max_correlation"] = 0.0 - diag["p95_correlation"] = 0.0 - diag["saturation"] = 0.0 - return diag def get_state_summary(self) -> dict: diff --git a/src/factorminer/core/library_io.py b/src/factorminer/core/library_io.py index ec45e34..2691c60 100644 --- a/src/factorminer/core/library_io.py +++ b/src/factorminer/core/library_io.py @@ -50,9 +50,9 @@ def save_library( "next_id": library._next_id, "factors": [f.to_dict() for f in library.list_factors()], } - if library.correlation_matrix is not None: - meta["correlation_matrix"] = library.correlation_matrix.tolist() - meta["id_to_index"] = {str(k): v for k, v in library._id_to_index.items()} + # 内存优化:相关性矩阵不再保存到 JSON + # if library.correlation_matrix is not None: + # meta["correlation_matrix"] = library.correlation_matrix.tolist() json_path = path.with_suffix(".json") with open(json_path, "w") as fp: @@ -94,17 +94,15 @@ def load_library(path: Union[str, Path]) -> FactorLibrary: factor = Factor.from_dict(fd) if factor.formula.strip().startswith("# TODO"): factor.metadata["unsupported"] = True + # 内存优化:signals 不再从 JSON 恢复,设为 None + factor.signals = None library.factors[factor.id] = factor - # Restore correlation matrix - if "correlation_matrix" in meta and meta["correlation_matrix"] is not None: - library.correlation_matrix = np.array( - meta["correlation_matrix"], dtype=np.float64 - ) - - # Restore id-to-index mapping - if "id_to_index" in meta: - library._id_to_index = {int(k): v for k, v in meta["id_to_index"].items()} + # 内存优化:相关性矩阵不再从 JSON 恢复(按需重算) + # if "correlation_matrix" in meta and meta["correlation_matrix"] is not None: + # library.correlation_matrix = np.array( + # meta["correlation_matrix"], dtype=np.float64 + # ) logger.info("Loaded library from %s (%d factors)", json_path, library.size) return library diff --git a/src/factorminer/core/ralph_loop.py b/src/factorminer/core/ralph_loop.py index b51d8e1..09a215d 100644 --- a/src/factorminer/core/ralph_loop.py +++ b/src/factorminer/core/ralph_loop.py @@ -41,7 +41,7 @@ from src.factorminer.memory.memory_store import ExperienceMemory from src.factorminer.memory.retrieval import retrieve_memory from src.factorminer.memory.formation import form_memory from src.factorminer.memory.evolution import evolve_memory -from src.factorminer.agent.llm_interface import LLMProvider, MockProvider +from src.factorminer.agent.llm_interface import LLMProvider from src.factorminer.agent.prompt_builder import PromptBuilder from src.factorminer.evaluation.metrics import ( compute_factor_stats, @@ -55,7 +55,10 @@ from src.factorminer.evaluation.research import ( compute_factor_geometry, passes_research_admission, ) -from src.factorminer.evaluation.runtime import SignalComputationError, compute_tree_signals +from src.factorminer.evaluation.runtime import ( + SignalComputationError, + compute_tree_signals, +) from src.factorminer.utils.logging import ( IterationRecord, FactorRecord, @@ -69,6 +72,7 @@ logger = logging.getLogger(__name__) # Budget Tracker # --------------------------------------------------------------------------- + @dataclass class BudgetTracker: """Tracks resource consumption across the mining session. @@ -77,7 +81,7 @@ class BudgetTracker: so the loop can stop early when a budget is exhausted. """ - max_llm_calls: int = 0 # 0 = unlimited + max_llm_calls: int = 0 # 0 = unlimited max_wall_seconds: float = 0 # 0 = unlimited # Running totals @@ -130,6 +134,7 @@ class BudgetTracker: # Candidate evaluation result # --------------------------------------------------------------------------- + @dataclass class EvaluationResult: """Result of evaluating a single candidate factor.""" @@ -143,9 +148,11 @@ class EvaluationResult: max_correlation: float = 0.0 correlated_with: str = "" admitted: bool = False - replaced: Optional[int] = None # ID of replaced factor, if any + replaced: Optional[int] = None # ID of replaced factor, if any rejection_reason: str = "" - stage_passed: int = 0 # 0=parse/IC fail, 1=IC pass, 2=corr pass, 3=dedup pass, 4=admitted + stage_passed: int = ( + 0 # 0=parse/IC fail, 1=IC pass, 2=corr pass, 3=dedup pass, 4=admitted + ) signals: Optional[np.ndarray] = None target_stats: Dict[str, dict] = field(default_factory=dict) research_score: float = 0.0 @@ -160,6 +167,7 @@ class EvaluationResult: # Factor Generator: wraps LLM + prompt builder + output parser # --------------------------------------------------------------------------- + class FactorGenerator: """Generates candidate factors using LLM guided by memory priors.""" @@ -168,7 +176,7 @@ class FactorGenerator: llm_provider: Optional[LLMProvider] = None, prompt_builder: Optional[PromptBuilder] = None, ) -> None: - self.llm = llm_provider or MockProvider() + self.llm = llm_provider self.prompt_builder = prompt_builder or PromptBuilder() def generate_batch( @@ -220,6 +228,7 @@ class FactorGenerator: # Validation Pipeline (lightweight orchestrator) # --------------------------------------------------------------------------- + class ValidationPipeline: """Multi-stage evaluation pipeline for candidate factors. @@ -245,9 +254,11 @@ class ValidationPipeline: num_workers: int = 1, research_config: Any = None, benchmark_mode: str = "paper", + evaluator: Any = None, ) -> None: self.data_tensor = data_tensor # (M, T, F) self.returns = returns # (M, T) + self.evaluator = evaluator self.target_panels = target_panels or {"paper": returns} self.target_horizons = target_horizons or {"paper": 1} self.library = library or FactorLibrary( @@ -278,6 +289,7 @@ class ValidationPipeline: name: str, formula: str, fast_screen: bool = True, + signals: Optional[np.ndarray] = None, ) -> EvaluationResult: """Evaluate a single candidate through the full pipeline. @@ -289,24 +301,35 @@ class ValidationPipeline: DSL formula string. fast_screen : bool If True, Stage 1 uses M_fast assets only. If False, uses all. + signals : np.ndarray, optional + Pre-computed signals. If provided, skip signal computation. """ result = EvaluationResult(factor_name=name, formula=formula) - # Stage 0: Parse - tree = try_parse(formula) - if tree is None: - result.rejection_reason = "Parse failure" + # Stage 0: 公式有效性检查(本地 DSL 不再使用旧解析器) + if formula.strip().startswith("# TODO"): + result.rejection_reason = "Unsupported formula" result.stage_passed = 0 return result result.parse_ok = True # Stage 1: Compute signals and fast IC screening - try: - signals = self._compute_signals(tree) - except SignalComputationError as exc: - result.rejection_reason = f"Signal computation error: {exc}" - result.stage_passed = 0 - return result + if signals is None: + try: + if self.evaluator is not None: + signals = self.evaluator.evaluate_single(name, formula) + else: + # Fallback to legacy path for backwards compatibility + tree = try_parse(formula) + if tree is None: + result.rejection_reason = "Parse failure" + result.stage_passed = 0 + return result + signals = self._compute_signals(tree) + except Exception as exc: + result.rejection_reason = f"Signal computation error: {exc}" + result.stage_passed = 0 + return result if signals is None or np.all(np.isnan(signals)): result.rejection_reason = "All-NaN signals" @@ -341,11 +364,17 @@ class ValidationPipeline: for target_name, target_returns in self.target_panels.items(): if target_name == "paper": continue - result.target_stats[target_name] = compute_factor_stats(signals, target_returns) + result.target_stats[target_name] = compute_factor_stats( + signals, target_returns + ) score_vector_obj = None if self._research_enabled(): - library_signals = [factor.signals for factor in self.library.list_factors() if factor.signals is not None] + library_signals = [ + factor.signals + for factor in self.library.list_factors() + if factor.signals is not None + ] geometry = compute_factor_geometry(signals, self.returns, library_signals) score_vector_obj = build_score_vector( result.target_stats, @@ -387,7 +416,9 @@ class ValidationPipeline: self.research_config, self.library.correlation_threshold, ) - result.max_correlation = result.score_vector["geometry"]["max_abs_correlation"] + result.max_correlation = result.score_vector["geometry"][ + "max_abs_correlation" + ] if admitted: result.admitted = True result.stage_passed = 3 @@ -404,14 +435,14 @@ class ValidationPipeline: # Stage 2: Correlation check against library (admission) admitted, reason = self.library.check_admission( - result.ic_mean, signals + result.ic_mean, signals, self.evaluator ) if admitted: result.admitted = True result.stage_passed = 3 if self.library.size > 0: result.max_correlation = self.library._max_correlation_with_library( - signals + signals, self.evaluator ) return result @@ -421,6 +452,7 @@ class ValidationPipeline: should_replace, replace_id, replace_reason = self.library.check_replacement( result.ic_mean, signals, + self.evaluator, ic_min=self.replacement_ic_min, ic_ratio=self.replacement_ic_ratio, ) @@ -428,7 +460,7 @@ class ValidationPipeline: result.admitted = True result.replaced = replace_id result.max_correlation = self.library._max_correlation_with_library( - signals + signals, self.evaluator ) result.stage_passed = 3 return result @@ -437,7 +469,7 @@ class ValidationPipeline: result.rejection_reason = reason if self.library.size > 0: result.max_correlation = self.library._max_correlation_with_library( - signals + signals, self.evaluator ) return result @@ -448,15 +480,20 @@ class ValidationPipeline: and self.benchmark_mode == "research" ) - def _research_replacement(self, result: EvaluationResult) -> tuple[Optional[int], str]: + def _research_replacement( + self, result: EvaluationResult + ) -> tuple[Optional[int], str]: if result.score_vector is None or self.library.size == 0: return None, result.rejection_reason conflicting: list[tuple[int, float]] = [] for factor in self.library.list_factors(): - if factor.signals is None: + factor_signals = self.library._get_factor_signals(factor, self.evaluator) + if factor_signals is None: continue - corr = self.library._compute_correlation_vectorized(result.signals, factor.signals) + corr = self.library._compute_correlation_vectorized( + result.signals, factor_signals + ) if corr >= self.library.correlation_threshold: conflicting.append((factor.id, corr)) if len(conflicting) != 1: @@ -464,8 +501,12 @@ class ValidationPipeline: target_id, _ = conflicting[0] target_factor = self.library.get_factor(target_id) - target_score = float(target_factor.research_metrics.get("primary_score", target_factor.ic_mean)) - if result.research_score < max(self.replacement_ic_min, self.replacement_ic_ratio * target_score): + target_score = float( + target_factor.research_metrics.get("primary_score", target_factor.ic_mean) + ) + if result.research_score < max( + self.replacement_ic_min, self.replacement_ic_ratio * target_score + ): return None, ( f"Research replacement score {result.research_score:.4f} " f"not strong enough to replace factor {target_id} ({target_score:.4f})" @@ -484,9 +525,18 @@ class ValidationPipeline: if self.num_workers > 1: results = self._evaluate_parallel(candidates) else: + # 单线程模式下批量预计算信号,避免重复调用 FactorEngine + precomputed_signals: Dict[str, np.ndarray] = {} + if self.evaluator is not None and candidates: + try: + precomputed_signals = self.evaluator.evaluate(candidates) + except Exception as exc: + logger.warning("批量预计算信号失败,回退到单因子计算: %s", exc) + results = [] for name, formula in candidates: - result = self.evaluate_candidate(name, formula) + signals = precomputed_signals.get(name) + result = self.evaluate_candidate(name, formula, signals=signals) results.append(result) # Stage 3: Intra-batch deduplication @@ -530,8 +580,7 @@ class ValidationPipeline: theta, keep the one with higher IC and reject the other. """ admitted_indices = [ - i for i, r in enumerate(results) - if r.admitted and r.signals is not None + i for i, r in enumerate(results) if r.admitted and r.signals is not None ] if len(admitted_indices) <= 1: @@ -545,7 +594,9 @@ class ValidationPipeline: admitted_by_ic = sorted( admitted_indices, key=lambda i: ( - results[i].research_score if self._research_enabled() else results[i].ic_mean + results[i].research_score + if self._research_enabled() + else results[i].ic_mean ), reverse=True, ) @@ -558,9 +609,7 @@ class ValidationPipeline: is_correlated = False for kept_sig in kept_signals: - corr = self.library._compute_correlation_vectorized( - r.signals, kept_sig - ) + corr = self.library._compute_correlation_vectorized(r.signals, kept_sig) if corr >= corr_threshold: is_correlated = True break @@ -590,7 +639,8 @@ class ValidationPipeline: if dedup_rejected > 0: logger.debug( "Intra-batch dedup: rejected %d/%d admitted candidates", - dedup_rejected, len(admitted_indices), + dedup_rejected, + len(admitted_indices), ) return results @@ -634,6 +684,7 @@ class ValidationPipeline: # Mining Reporter # --------------------------------------------------------------------------- + class MiningReporter: """Lightweight reporter that logs batch results to a JSONL file.""" @@ -649,9 +700,7 @@ class MiningReporter: with open(self._log_path, "a") as f: f.write(json.dumps(record, default=str) + "\n") - def export_library( - self, library: FactorLibrary, path: Optional[str] = None - ) -> str: + def export_library(self, library: FactorLibrary, path: Optional[str] = None) -> str: """Export the factor library to JSON.""" if path is None: path = str(self.output_dir / "factor_library.json") @@ -671,6 +720,7 @@ class MiningReporter: # The Ralph Loop # --------------------------------------------------------------------------- + class RalphLoop: """Self-Evolving Factor Discovery via the Ralph Loop paradigm. @@ -687,12 +737,12 @@ class RalphLoop: def __init__( self, config: Any, - data_tensor: np.ndarray, returns: np.ndarray, llm_provider: Optional[LLMProvider] = None, memory: Optional[ExperienceMemory] = None, library: Optional[FactorLibrary] = None, checkpoint_interval: int = 1, + evaluator: Any = None, ) -> None: """Initialize the Ralph Loop. @@ -700,8 +750,6 @@ class RalphLoop: ---------- config : MiningConfig Mining configuration (from core.config or utils.config). - data_tensor : np.ndarray - Market data tensor D in R^(M x T x F). returns : np.ndarray Forward returns array R in R^(M x T). llm_provider : LLMProvider, optional @@ -713,10 +761,14 @@ class RalphLoop: checkpoint_interval : int Save a checkpoint every N iterations. Set to 0 to disable automatic checkpointing. Default is 1 (every iteration). + evaluator : LocalFactorEvaluator, optional + Local factor evaluator wrapping FactorEngine. If provided, + signals are computed via the local framework instead of + the legacy data_tensor path. """ self.config = config - self.data_tensor = data_tensor self.returns = returns + self.evaluator = evaluator self.checkpoint_interval = checkpoint_interval # Core components @@ -731,7 +783,7 @@ class RalphLoop: prompt_builder=PromptBuilder(), ) self.pipeline = ValidationPipeline( - data_tensor=data_tensor, + data_tensor=np.empty((returns.shape[0], returns.shape[1], 1)), returns=returns, target_panels=getattr(config, "target_panels", None), target_horizons=getattr(config, "target_horizons", None), @@ -744,13 +796,12 @@ class RalphLoop: num_workers=getattr(config, "num_workers", 1), research_config=getattr(config, "research", None), benchmark_mode=getattr(config, "benchmark_mode", "paper"), + evaluator=evaluator, ) self.pipeline.signal_failure_policy = getattr( config, "signal_failure_policy", "reject" ) - self.reporter = MiningReporter( - getattr(config, "output_dir", "./output") - ) + self.reporter = MiningReporter(getattr(config, "output_dir", "./output")) self.budget = BudgetTracker() self.signal_failure_policy = getattr(config, "signal_failure_policy", "reject") @@ -790,12 +841,8 @@ class RalphLoop: FactorLibrary The constructed factor library L. """ - target_size = target_size or getattr( - self.config, "target_library_size", 110 - ) - max_iterations = max_iterations or getattr( - self.config, "max_iterations", 200 - ) + target_size = target_size or getattr(self.config, "target_library_size", 110) + max_iterations = max_iterations or getattr(self.config, "max_iterations", 200) batch_size = getattr(self.config, "batch_size", 40) output_dir = getattr(self.config, "output_dir", "./output") @@ -830,12 +877,14 @@ class RalphLoop: # Initialize session logger self._session_logger = MiningSessionLogger(output_dir) - self._session_logger.log_session_start({ - "target_library_size": target_size, - "batch_size": batch_size, - "max_iterations": max_iterations, - "resumed_from_iteration": self.iteration if resume else 0, - }) + self._session_logger.log_session_start( + { + "target_library_size": target_size, + "batch_size": batch_size, + "max_iterations": max_iterations, + "resumed_from_iteration": self.iteration if resume else 0, + } + ) self._session_logger.start_progress(max_iterations) loop_start = time.time() @@ -845,10 +894,7 @@ class RalphLoop: self.budget.wall_start = time.time() try: - while ( - self.library.size < target_size - and self.iteration < max_iterations - ): + while self.library.size < target_size and self.iteration < max_iterations: # Check budget BEFORE starting a new iteration if self.budget.is_exhausted(): logger.info("Budget exhausted — stopping loop") @@ -1054,7 +1100,9 @@ class RalphLoop: admitted.append(result) logger.info( "Replaced factor %d with '%s' (IC=%.4f)", - old_id, result.factor_name, result.ic_mean, + old_id, + result.factor_name, + result.ic_mean, ) except KeyError: logger.warning( @@ -1123,9 +1171,9 @@ class RalphLoop: # Count dedup rejections (stage_passed==2 with dedup reason) dedup_rejected = sum( - 1 for r in results - if not r.admitted - and "deduplication" in r.rejection_reason.lower() + 1 + for r in results + if not r.admitted and "deduplication" in r.rejection_reason.lower() ) return { @@ -1239,9 +1287,9 @@ class RalphLoop: checkpoint_dir = Path(output_dir) / "checkpoint" checkpoint_dir.mkdir(parents=True, exist_ok=True) - # Save library using library_io (JSON + optional signal cache) + # Save library using library_io (JSON only, signals cache disabled) lib_base = str(checkpoint_dir / "library") - save_library(self.library, lib_base, save_signals=True) + save_library(self.library, lib_base, save_signals=False) # Save memory using ExperienceMemoryManager if available, # otherwise fall back to raw ExperienceMemory serialization @@ -1356,7 +1404,7 @@ class RalphLoop: len(self.memory.insights), ) - # Load library using library_io (supports signals + correlation matrix) + # Load library using library_io (signals and correlation_matrix no longer saved) lib_json_path = checkpoint_dir / "library.json" if lib_json_path.exists(): lib_base = str(checkpoint_dir / "library") @@ -1364,8 +1412,7 @@ class RalphLoop: # Merge into current library (preserving thresholds from config) self.library.factors = loaded_library.factors self.library._next_id = loaded_library._next_id - self.library._id_to_index = loaded_library._id_to_index - self.library.correlation_matrix = loaded_library.correlation_matrix + # 内存优化:correlation_matrix 不再从 checkpoint 恢复 # Update the pipeline reference so it uses the restored library self.pipeline.library = self.library logger.info("Loaded library with %d factors", self.library.size) @@ -1388,7 +1435,6 @@ class RalphLoop: cls, checkpoint_path: str, config: Any, - data_tensor: np.ndarray, returns: np.ndarray, llm_provider: Optional[LLMProvider] = None, **kwargs: Any, @@ -1399,7 +1445,7 @@ class RalphLoop: ---------- checkpoint_path : str Path to the checkpoint directory. - config, data_tensor, returns, llm_provider + config, returns, llm_provider Same as ``__init__``. Returns @@ -1409,7 +1455,6 @@ class RalphLoop: """ loop = cls( config=config, - data_tensor=data_tensor, returns=returns, llm_provider=llm_provider, **kwargs, @@ -1437,9 +1482,15 @@ class RalphLoop: except (TypeError, AttributeError): # Fallback: extract known attributes attrs = [ - "target_library_size", "batch_size", "max_iterations", - "ic_threshold", "icir_threshold", "correlation_threshold", - "replacement_ic_min", "replacement_ic_ratio", "output_dir", + "target_library_size", + "batch_size", + "max_iterations", + "ic_threshold", + "icir_threshold", + "correlation_threshold", + "replacement_ic_min", + "replacement_ic_ratio", + "output_dir", ] return { attr: getattr(self.config, attr, None) @@ -1467,7 +1518,6 @@ class RalphLoop: config_summary = self._serialize_config() dataset_summary = { - "data_tensor_shape": list(self.data_tensor.shape), "returns_shape": list(self.returns.shape), "memory_version": self.memory.version, "library_size": self.library.size, @@ -1478,17 +1528,12 @@ class RalphLoop: target_stack = list(self.config.get("target_stack", [])) else: benchmark_mode = str(getattr(self.config, "benchmark_mode", "paper")) - target_stack = list( - getattr(self.config, "target_stack", []) - or [] - ) + target_stack = list(getattr(self.config, "target_stack", []) or []) pipeline_targets = getattr(self.pipeline, "target_panels", None) or {} if pipeline_targets: target_stack = [ - name - for name in pipeline_targets.keys() - if name and name != "paper" + name for name in pipeline_targets.keys() if name and name != "paper" ] or target_stack manifest = build_run_manifest( diff --git a/src/factorminer/data/__init__.py b/src/factorminer/data/__init__.py index 2931d9b..cf7abe9 100644 --- a/src/factorminer/data/__init__.py +++ b/src/factorminer/data/__init__.py @@ -7,11 +7,6 @@ from src.factorminer.data.loader import ( load_multiple, to_numpy, ) -from src.factorminer.data.mock_data import ( - MockConfig, - generate_mock_data, - generate_with_halts, -) from src.factorminer.data.preprocessor import ( PreprocessConfig, compute_derived_features, @@ -46,9 +41,6 @@ __all__ = [ "load_multiple", "to_numpy", # mock_data - "MockConfig", - "generate_mock_data", - "generate_with_halts", # preprocessor "PreprocessConfig", "compute_derived_features", diff --git a/src/factorminer/data/mock_data.py b/src/factorminer/data/mock_data.py deleted file mode 100644 index 64a892e..0000000 --- a/src/factorminer/data/mock_data.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Generate realistic synthetic market data for testing FactorMiner. - -Produces multi-asset OHLCV data with: -- Volume clustering (GARCH-like) -- Volatility clustering -- Cross-sectional correlation via a common market factor -- Planted alpha signals for validating factor discovery -- OHLC consistency guarantees: low <= open,close <= high -""" - -from __future__ import annotations - -import logging -from dataclasses import dataclass -from typing import Literal, Optional - -import numpy as np -import pandas as pd - -logger = logging.getLogger(__name__) - -Frequency = Literal["10min", "30min", "1h", "1d"] - -_FREQ_MAP = { - "10min": "10min", - "30min": "30min", - "1h": "1h", - "1d": "1D", -} - - -@dataclass -class MockConfig: - """Configuration for synthetic data generation. - - Attributes - ---------- - num_assets : int - Number of assets (M). - num_periods : int - Number of time bars (T) per asset. - frequency : str - Bar frequency: ``"10min"``, ``"30min"``, ``"1h"``, ``"1d"``. - start_date : str - Start datetime in ISO format. - base_price : float - Initial price level around which assets are generated. - annual_vol : float - Annualised volatility for the diffusion process. - market_factor_weight : float - Weight of the common market factor in returns (0-1). - Higher values increase cross-sectional correlation. - vol_persistence : float - GARCH(1,1) persistence parameter for volatility clustering (0-1). - volume_mean : float - Mean daily volume per asset. - volume_persistence : float - AR(1) coefficient for volume clustering (0-1). - plant_alpha : bool - Whether to inject planted alpha signals. - alpha_strength : float - Signal-to-noise ratio of the planted alpha. - alpha_assets_frac : float - Fraction of assets that carry the planted signal. - seed : int - Random seed for reproducibility. - universe : str or None - Universe label to include in the output. - """ - - num_assets: int = 50 - num_periods: int = 1000 - frequency: Frequency = "10min" - start_date: str = "2024-01-02 09:30:00" - base_price: float = 50.0 - annual_vol: float = 0.25 - market_factor_weight: float = 0.3 - vol_persistence: float = 0.9 - volume_mean: float = 1_000_000.0 - volume_persistence: float = 0.85 - plant_alpha: bool = True - alpha_strength: float = 0.02 - alpha_assets_frac: float = 0.2 - seed: int = 42 - universe: Optional[str] = None - - -def _bars_per_year(freq: Frequency) -> float: - """Approximate number of bars in a trading year.""" - trading_days = 252 - bars_per_day = { - "10min": 24, # 4h session / 10min - "30min": 8, - "1h": 4, - "1d": 1, - } - return trading_days * bars_per_day[freq] - - -def _generate_timestamps( - start: str, - num_periods: int, - freq: Frequency, -) -> pd.DatetimeIndex: - """Create a business-aware timestamp index. - - For intraday frequencies the index skips weekends and only covers - a simplified trading session (09:30 - 15:00 for 10min/30min bars). - """ - pd_freq = _FREQ_MAP[freq] - if freq == "1d": - ts = pd.bdate_range(start=start, periods=num_periods, freq="B") - else: - # Generate enough intraday bars, then trim to num_periods - days_needed = (num_periods // 24) + 10 # generous overestimate - day_range = pd.bdate_range(start=start, periods=days_needed, freq="B") - bars: list[pd.Timestamp] = [] - for day in day_range: - session_start = day.replace(hour=9, minute=30, second=0) - session_end = day.replace(hour=15, minute=0, second=0) - day_bars = pd.date_range(session_start, session_end, freq=pd_freq) - # Exclude the exact session end for cleaner bars - day_bars = day_bars[day_bars < session_end] - bars.extend(day_bars.tolist()) - if len(bars) >= num_periods: - break - ts = pd.DatetimeIndex(bars[:num_periods]) - return ts - - -def generate_mock_data(config: Optional[MockConfig] = None) -> pd.DataFrame: - """Generate synthetic multi-asset OHLCV + amount data. - - Parameters - ---------- - config : MockConfig, optional - Generation parameters. Uses defaults when *None*. - - Returns - ------- - pd.DataFrame - DataFrame with columns: datetime, asset_id, open, high, low, - close, volume, amount. Optionally includes ``universe``. - """ - if config is None: - config = MockConfig() - - rng = np.random.default_rng(config.seed) - M = config.num_assets - T = config.num_periods - - logger.info("Generating mock data: %d assets x %d periods @ %s", M, T, config.frequency) - - timestamps = _generate_timestamps(config.start_date, T, config.frequency) - T = len(timestamps) # may be shorter if we ran out of session bars - - # Per-bar volatility (annualised -> per-bar) - bar_vol = config.annual_vol / np.sqrt(_bars_per_year(config.frequency)) - - # --------------------------------------------------------------- - # Common market factor (drives cross-sectional correlation) - # --------------------------------------------------------------- - market_returns = rng.normal(0, bar_vol, size=T) - - # --------------------------------------------------------------- - # Per-asset paths - # --------------------------------------------------------------- - asset_ids = [f"ASSET_{i:04d}" for i in range(M)] - - # Storage - all_open = np.empty((M, T)) - all_high = np.empty((M, T)) - all_low = np.empty((M, T)) - all_close = np.empty((M, T)) - all_volume = np.empty((M, T)) - all_amount = np.empty((M, T)) - - # Planted alpha: select a subset of assets - n_alpha = max(1, int(M * config.alpha_assets_frac)) - alpha_assets = set(rng.choice(M, size=n_alpha, replace=False).tolist()) if config.plant_alpha else set() - - for i in range(M): - # Initial price with some dispersion - p0 = config.base_price * np.exp(rng.normal(0, 0.3)) - - # GARCH-like stochastic volatility - sigma = np.empty(T) - sigma[0] = bar_vol - for t in range(1, T): - sigma[t] = ( - bar_vol * (1 - config.vol_persistence) - + config.vol_persistence * sigma[t - 1] - + rng.normal(0, bar_vol * 0.1) - ) - sigma[t] = max(sigma[t], bar_vol * 0.2) # floor - - # Idiosyncratic returns - idio = rng.normal(0, 1, size=T) * sigma - - # Combine with market factor - w = config.market_factor_weight - returns = w * market_returns + (1 - w) * idio - - # Plant alpha signal: small positive drift in returns - if i in alpha_assets: - # Signal: positive drift correlated with lagged volume momentum - alpha_drift = config.alpha_strength * bar_vol - returns += alpha_drift - - # Cumulative price path (close prices) - log_price = np.log(p0) + np.cumsum(returns) - close = np.exp(log_price) - - # Generate intra-bar OHLC from close - # Open = previous close + small gap noise - open_ = np.empty(T) - open_[0] = p0 - open_[1:] = close[:-1] * np.exp(rng.normal(0, bar_vol * 0.1, size=T - 1)) - - # Intra-bar high/low - intra_range = np.abs(rng.normal(0, sigma * 0.5, size=T)) - mid = (open_ + close) / 2 - high = np.maximum(open_, close) + intra_range - low = np.minimum(open_, close) - intra_range - low = np.maximum(low, mid * 0.9) # prevent negative or absurd lows - - # Enforce OHLC consistency - high = np.maximum(high, np.maximum(open_, close)) - low = np.minimum(low, np.minimum(open_, close)) - low = np.maximum(low, 0.01) # price floor - - # Volume: AR(1) with log-normal noise - log_vol = np.empty(T) - log_vol_mean = np.log(config.volume_mean) - log_vol[0] = log_vol_mean + rng.normal(0, 0.5) - for t in range(1, T): - log_vol[t] = ( - log_vol_mean * (1 - config.volume_persistence) - + config.volume_persistence * log_vol[t - 1] - + rng.normal(0, 0.3) - ) - volume = np.exp(log_vol).astype(np.float64) - - # Amount = volume * vwap (approximate vwap as midpoint) - vwap_est = (high + low + close) / 3 - amount = volume * vwap_est - - all_open[i] = open_ - all_high[i] = high - all_low[i] = low - all_close[i] = close - all_volume[i] = np.round(volume) - all_amount[i] = amount - - # --------------------------------------------------------------- - # Assemble DataFrame - # --------------------------------------------------------------- - records = [] - for i in range(M): - asset_df = pd.DataFrame({ - "datetime": timestamps, - "asset_id": asset_ids[i], - "open": all_open[i], - "high": all_high[i], - "low": all_low[i], - "close": all_close[i], - "volume": all_volume[i], - "amount": all_amount[i], - }) - records.append(asset_df) - - df = pd.concat(records, ignore_index=True) - - if config.universe is not None: - df["universe"] = config.universe - - df = df.sort_values(["datetime", "asset_id"]).reset_index(drop=True) - - logger.info( - "Generated %d rows: %d assets x %d periods, planted alpha in %d assets", - len(df), - M, - T, - len(alpha_assets), - ) - return df - - -def generate_with_halts( - config: Optional[MockConfig] = None, - halt_fraction: float = 0.01, -) -> pd.DataFrame: - """Generate mock data with simulated trading halts. - - A fraction of (asset, time) pairs are converted to halt bars: - open = high = low = close = last valid close, volume = 0, amount = 0. - - Parameters - ---------- - config : MockConfig, optional - Generation parameters. - halt_fraction : float - Fraction of bars to convert to halts. - """ - df = generate_mock_data(config) - if config is None: - config = MockConfig() - rng = np.random.default_rng(config.seed + 1) - - n = len(df) - n_halt = int(n * halt_fraction) - halt_idx = rng.choice(n, size=n_halt, replace=False) - - df.loc[halt_idx, "volume"] = 0 - df.loc[halt_idx, "amount"] = 0 - # Flatten OHLC to close (simulating last traded price) - halt_price = df.loc[halt_idx, "close"] - df.loc[halt_idx, "open"] = halt_price - df.loc[halt_idx, "high"] = halt_price - df.loc[halt_idx, "low"] = halt_price - - logger.info("Injected %d halt bars (%.2f%%)", n_halt, 100 * halt_fraction) - return df diff --git a/src/factorminer/evaluation/local_engine.py b/src/factorminer/evaluation/local_engine.py index ff5ffde..35080d6 100644 --- a/src/factorminer/evaluation/local_engine.py +++ b/src/factorminer/evaluation/local_engine.py @@ -67,7 +67,8 @@ class LocalFactorEvaluator: if not specs: return {} - print(f"[local_engine] 开始批量计算 {len(specs)} 个因子...") + if len(specs) > 1: + print(f"[local_engine] 开始批量计算 {len(specs)} 个因子...") # 注册所有因子 for name, formula in specs: @@ -96,7 +97,8 @@ class LocalFactorEvaluator: # 清理注册的因子 self.engine.clear() - print(f"[local_engine] 批量计算完成,返回 {len(signals_dict)} 个因子") + if len(specs) > 1: + print(f"[local_engine] 批量计算完成,返回 {len(signals_dict)} 个因子") return signals_dict def evaluate_single( @@ -196,8 +198,8 @@ class LocalFactorEvaluator: # 使用 Polars 的 pivot 操作 try: - pivot_df = df.pivot( - values="value", + pivot_df = df.select(["ts_code", "trade_date", name]).pivot( + values=name, on="trade_date", index="ts_code", aggregate_function="first", diff --git a/src/factorminer/main.py b/src/factorminer/main.py new file mode 100644 index 0000000..adab8f5 --- /dev/null +++ b/src/factorminer/main.py @@ -0,0 +1,263 @@ +"""FactorMiner 本地框架集成入口。 + +通过纯字典配置运行因子挖掘主循环,数据计算完全复用本地 FactorEngine, +不再依赖外部数据文件(data_path)。 +""" + +import os +import sys +from pathlib import Path +from typing import Any, Optional + +import numpy as np + +from src.config.settings import get_settings +from src.factorminer.agent.llm_interface import create_provider, AnthropicProvider +from src.factorminer.core.config import MiningConfig as CoreMiningConfig +from src.factorminer.core.library_io import import_from_paper, save_library +from src.factorminer.core.ralph_loop import RalphLoop +from src.factorminer.evaluation.local_engine import LocalFactorEvaluator +from src.factorminer.utils.config import load_config + +RUN_CONFIG: dict = { + # 全局开关 + "mock": False, # True: 使用 Mock LLM(无需 API Key) + "verbose": True, # True: 输出 DEBUG 级别日志 + "cpu": False, # True: 强制 CPU 后端 + # 路径 + "output_dir": "./output", # 输出目录 + "resume": None, # 从已有 checkpoint 恢复(可选) + # 本地数据范围(FactorEngine 自动读取 DuckDB) + "start_date": "20200101", # 计算开始日期 + "end_date": "20201231", # 计算结束日期 + "stock_codes": None, # 可选股票列表,None 表示全量 + # 种子库 + "seed_paper_library": True, # 是否预加载 110 Paper Factors 作为种子库 + # 挖掘配置(覆盖 default.yaml 中的同名项) + "mining": { + "max_iterations": 3, + "target_library_size": 10, + "correlation_threshold": 0.50, + "ic_threshold": 0.04, + "icir_threshold": 0.50, + "replacement_ic_min": 0.10, + "replacement_ic_ratio": 1.30, + "fast_screen_assets": 100, + "num_workers": 1, + }, + # LLM 配置(mock=False 时使用) + "llm": { + "provider": "anthropic", + "model": "MiniMax-M2.7", + "api_key": None, # 可直接配置 API Key,也可通过环境变量 ANTHROPIC_API_KEY 传入 + "temperature": 0.7, + "max_tokens": 2048, + }, +} + + +def _create_llm_provider(run_cfg: dict) -> Any: + """根据配置创建 LLM Provider。""" + llm_cfg = run_cfg.get("llm", {}) + provider = llm_cfg.get("provider", "openai") + model = llm_cfg.get("model", "gpt-4o") + api_key = llm_cfg.get("api_key") or os.environ.get("OPENAI_API_KEY") + temperature = llm_cfg.get("temperature", 0.7) + max_tokens = llm_cfg.get("max_tokens", 2048) + + if provider == "openai": + if not api_key: + raise ValueError( + "OpenAI API key 未配置。请设置 OPENAI_API_KEY 环境变量," + "或在 RUN_CONFIG['llm']['api_key'] 中填入 API key。" + ) + return create_provider( + { + "provider": "openai", + "model": model, + "api_key": api_key, + "temperature": temperature, + "max_tokens": max_tokens, + } + ) + + if provider == "anthropic": + api_key = ( + llm_cfg.get("api_key") + or get_settings().anthropic_api_key + or os.environ.get("ANTHROPIC_API_KEY") + ) + if not api_key: + raise ValueError( + "Anthropic API key 未配置。请设置 ANTHROPIC_API_KEY 环境变量," + "或在 config/.env.local / RUN_CONFIG['llm']['api_key'] 中填入 API key。" + ) + return AnthropicProvider( + model=model, + api_key=api_key, + use_thinking=False, + ) + + +def _build_core_mining_config(run_cfg: dict) -> CoreMiningConfig: + """从 RUN_CONFIG 构建 RalphLoop 需要的 MiningConfig。""" + mining = run_cfg.get("mining", {}) + + cfg = CoreMiningConfig( + max_iterations=mining.get("max_iterations", 3), + target_library_size=mining.get("target_library_size", 10), + correlation_threshold=mining.get("correlation_threshold", 0.50), + ic_threshold=mining.get("ic_threshold", 0.04), + replacement_ic_min=mining.get("replacement_ic_min", 0.10), + replacement_ic_ratio=mining.get("replacement_ic_ratio", 1.30), + fast_screen_assets=mining.get("fast_screen_assets", 100), + num_workers=mining.get("num_workers", 1), + ) + + # research 等高级配置暂设为 None(单目标模式) + setattr(cfg, "target_panels", None) + setattr(cfg, "target_horizons", None) + setattr(cfg, "benchmark_mode", "paper") + return cfg + + +def main(config: dict | None = None) -> None: + """运行因子挖掘主循环。 + + Args: + config: 运行配置字典。若未提供,则使用 RUN_CONFIG 的副本。 + """ + run_cfg = config if config is not None else RUN_CONFIG.copy() + + # ------------------------------------------------------------------ + # 1. 加载并合并 default.yaml 默认配置 + # ------------------------------------------------------------------ + try: + cfg = load_config() + except Exception as e: + print(f"[WARN] 加载 default.yaml 失败,使用内置默认值: {e}") + cfg = None + + if cfg is not None: + mining_overrides = run_cfg.get("mining", {}) + for key, val in mining_overrides.items(): + if hasattr(cfg.mining, key): + setattr(cfg.mining, key, val) + + llm_overrides = run_cfg.get("llm", {}) + for key, val in llm_overrides.items(): + if hasattr(cfg.llm, key): + setattr(cfg.llm, key, val) + else: + cfg = run_cfg + + # ------------------------------------------------------------------ + # 2. 确定输出目录 + # ------------------------------------------------------------------ + output_dir = Path(run_cfg.get("output_dir", "./output")) + output_dir.mkdir(parents=True, exist_ok=True) + print(f"[main] 输出目录: {output_dir.resolve()}") + + # ------------------------------------------------------------------ + # 3. 初始化 LLM Provider + # ------------------------------------------------------------------ + provider = _create_llm_provider(run_cfg) + print(f"[main] LLM Provider: {provider.__class__.__name__}") + + # ------------------------------------------------------------------ + # 4. 创建本地因子评估器(FactorEngine 自动读取数据) + # ------------------------------------------------------------------ + start_date = run_cfg.get("start_date", "20200101") + end_date = run_cfg.get("end_date", "20201231") + stock_codes = run_cfg.get("stock_codes") + + evaluator = LocalFactorEvaluator( + start_date=start_date, + end_date=end_date, + stock_codes=stock_codes, + ) + returns = evaluator.evaluate_returns(periods=1) + print( + f"[main] 本地数据范围: {start_date} ~ {end_date}, returns shape: {returns.shape}" + ) + + # ------------------------------------------------------------------ + # 5. 构建 MiningConfig + # ------------------------------------------------------------------ + mining_cfg = _build_core_mining_config(run_cfg) + print( + f"[main] 挖掘配置: max_iterations={mining_cfg.max_iterations}, " + f"target_library_size={mining_cfg.target_library_size}" + ) + + # ------------------------------------------------------------------ + # 6. 恢复 checkpoint(可选) + # ------------------------------------------------------------------ + resume_path: Optional[str] = run_cfg.get("resume") + if resume_path is not None and Path(resume_path).exists(): + print(f"[main] 从 checkpoint 恢复: {resume_path}") + loop = RalphLoop.resume_from( + checkpoint_path=resume_path, + config=mining_cfg, + returns=returns, + llm_provider=provider, + evaluator=evaluator, + ) + else: + loop = RalphLoop( + config=mining_cfg, + returns=returns, + llm_provider=provider, + evaluator=evaluator, + ) + + # ------------------------------------------------------------------ + # 7. 预加载 Paper Factor 作为种子库(可选) + # ------------------------------------------------------------------ + if run_cfg.get("seed_paper_library", True): + try: + paper_lib = import_from_paper() + loop.library = paper_lib + print(f"[main] 已加载 Paper Factor 种子库: {paper_lib.size} 个因子") + except Exception as e: + print(f"[WARN] 加载 Paper Factor 种子库失败: {e}") + + # ------------------------------------------------------------------ + # 7.5 确保目标库大小大于当前种子库,否则循环不会执行 + # ------------------------------------------------------------------ + if loop.library.size >= mining_cfg.target_library_size: + old_target = mining_cfg.target_library_size + new_target = loop.library.size + 10 + setattr(mining_cfg, "target_library_size", new_target) + print( + f"[WARN] 当前因子库({loop.library.size})已不小于目标大小({old_target})," + f"自动调整 target_library_size 为 {new_target} 以启动挖掘循环" + ) + + # ------------------------------------------------------------------ + # 8. 运行挖掘循环 + # ------------------------------------------------------------------ + try: + loop.run() + except KeyboardInterrupt: + print("[main] 用户中断,正在保存当前进度...") + loop.save_session() + except Exception as e: + print(f"[ERROR] 挖掘循环异常: {e}") + raise + + # ------------------------------------------------------------------ + # 9. 保存最终因子库 + # ------------------------------------------------------------------ + save_path = output_dir / "factor_library" + try: + save_library(loop.library, str(save_path)) + print(f"[main] 因子库已保存: {save_path}") + except Exception as e: + print(f"[ERROR] 保存因子库失败: {e}") + + print(f"[main] 挖掘完成。最终库大小: {loop.library.size}") + + +if __name__ == "__main__": + main() diff --git a/src/factorminer/output/checkpoint/helix_state.json b/src/factorminer/output/checkpoint/helix_state.json deleted file mode 100644 index 976596b..0000000 --- a/src/factorminer/output/checkpoint/helix_state.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "no_admission_streak": 1, - "forgetting_lambda": 0.95, - "canonicalize": false, - "enable_knowledge_graph": false, - "enable_embeddings": false, - "enable_auto_inventor": false -} \ No newline at end of file diff --git a/src/factorminer/output/checkpoint/library.json b/src/factorminer/output/checkpoint/library.json deleted file mode 100644 index 4a11f1f..0000000 --- a/src/factorminer/output/checkpoint/library.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "correlation_threshold": 0.5, - "ic_threshold": 0.04, - "next_id": 1, - "factors": [], - "id_to_index": {} -} \ No newline at end of file diff --git a/src/factorminer/output/checkpoint/library_signals.npz b/src/factorminer/output/checkpoint/library_signals.npz deleted file mode 100644 index ec25499e43f302407f1567a8e03c7568c844045a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4140 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 diff --git a/src/factorminer/output/checkpoint/loop_state.json b/src/factorminer/output/checkpoint/loop_state.json deleted file mode 100644 index cedf94f..0000000 --- a/src/factorminer/output/checkpoint/loop_state.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "iteration": 1, - "library_size": 0, - "memory_version": 1, - "budget": { - "llm_calls": 1, - "llm_prompt_tokens": 0, - "llm_completion_tokens": 0, - "compute_seconds": 0.008219003677368164, - "max_llm_calls": 0, - "max_wall_seconds": 0 - } -} \ No newline at end of file diff --git a/src/factorminer/output/checkpoint/memory.json b/src/factorminer/output/checkpoint/memory.json deleted file mode 100644 index 05e653e..0000000 --- a/src/factorminer/output/checkpoint/memory.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "state": { - "library_size": 0, - "recent_admissions": [], - "recent_rejections": [ - { - "factor_id": "momentum_reversal", - "formula": "Neg(CsRank(Delta($close, 5)))", - "reason": "Signal computation error: Expression evaluation failed for 'Neg(CsRank(Delta($close, 5)))': \"Feature '$close' not found in data. Available: ['$high', '$low', '$open']\"", - "max_correlation": 0.0, - "batch": 1 - }, - { - "factor_id": "volume_surprise", - "formula": "CsZScore(Div(Sub($volume, Mean($volume, 20)), Std($volume, 20)))", - "reason": "Signal computation error: Expression evaluation failed for 'CsZScore(Div(Sub($volume, Mean($volume, 20)), Std($volume, 20)))': \"Feature '$volume' not found in data. Available: ['$high', '$low', '$open']\"", - "max_correlation": 0.0, - "batch": 1 - }, - { - "factor_id": "price_range_ratio", - "formula": "Div(Sub($high, $low), Add($high, $low))", - "reason": "ICIR -0.3679 < threshold 0.5", - "max_correlation": 0.0, - "batch": 1 - }, - { - "factor_id": "vwap_deviation", - "formula": "CsRank(Div(Sub($close, $vwap), $vwap))", - "reason": "Signal computation error: Expression evaluation failed for 'CsRank(Div(Sub($close, $vwap), $vwap))': \"Feature '$close' not found in data. Available: ['$high', '$low', '$open']\"", - "max_correlation": 0.0, - "batch": 1 - }, - { - "factor_id": "return_skew", - "formula": "Neg(Skew($returns, 20))", - "reason": "Signal computation error: Expression evaluation failed for 'Neg(Skew($returns, 20))': \"Feature '$returns' not found in data. Available: ['$high', '$low', '$open']\"", - "max_correlation": 0.0, - "batch": 1 - } - ], - "domain_saturation": { - "Other": 0.0 - }, - "admission_log": [ - { - "batch": 1, - "admitted": 0, - "rejected": 5, - "admission_rate": 0.0 - } - ] - }, - "success_patterns": [], - "forbidden_directions": [], - "insights": [ - { - "insight": "Current direction is exhausted, need to pivot to new operator combinations", - "evidence": "Batch 1: only 0/5 admitted (0%)", - "batch_source": 1 - } - ], - "version": 1 -} \ No newline at end of file diff --git a/src/factorminer/output/checkpoint/session.json b/src/factorminer/output/checkpoint/session.json deleted file mode 100644 index fefd2d7..0000000 --- a/src/factorminer/output/checkpoint/session.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "session_id": "20260319_125236", - "config": { - "target_library_size": 3, - "batch_size": 5, - "max_iterations": 1, - "ic_threshold": 0.04, - "icir_threshold": 0.5, - "correlation_threshold": 0.5, - "replacement_ic_min": 0.1, - "replacement_ic_ratio": 1.3, - "fast_screen_assets": 100, - "num_workers": 40, - "output_dir": "./output", - "gpu_device": "cuda:0", - "backend": "numpy", - "signal_failure_policy": "reject" - }, - "output_dir": "./output", - "start_time": "2026-03-19T12:52:36.633890", - "end_time": "", - "status": "running", - "total_iterations": 1, - "last_library_size": 0, - "library_path": "output/checkpoint/library", - "memory_path": "output/checkpoint/memory.json", - "iterations": [ - { - "iteration": 1, - "candidates": 5, - "parse_ok": 5, - "ic_passed": 0, - "corr_passed": 0, - "dedup_rejected": 0, - "admitted": 0, - "replaced": 0, - "yield_rate": 0.0, - "library_size": 0, - "avg_correlation": 0.0, - "max_correlation": 0.0, - "elapsed_seconds": 0.008219003677368164, - "budget": { - "llm_calls": 1, - "llm_prompt_tokens": 0, - "llm_completion_tokens": 0, - "total_tokens": 0, - "compute_seconds": 0.01, - "wall_elapsed_seconds": 0.01 - }, - "candidates_before_canon": 5, - "canonical_duplicates_removed": 0, - "phase2_rejections": 0, - "timestamp": "2026-03-19T12:52:36.643364" - } - ] -} \ No newline at end of file diff --git a/src/factorminer/output/factor_library.json b/src/factorminer/output/factor_library.json deleted file mode 100644 index dd7070a..0000000 --- a/src/factorminer/output/factor_library.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "factors": [], - "diagnostics": { - "size": 0, - "category_counts": {}, - "category_avg_ic": {}, - "avg_correlation": 0.0, - "max_correlation": 0.0, - "p95_correlation": 0.0, - "saturation": 0.0 - }, - "exported_at": "2026-03-19T12:52:36.644886" -} \ No newline at end of file diff --git a/src/factorminer/output/factor_library_signals.npz b/src/factorminer/output/factor_library_signals.npz deleted file mode 100644 index 3c977f495c400da04453c9905b966745e1fd06d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31289 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 kLW0tNa>n-dvs6$Gu zie)Wg`GXtT6Ot$I5Gs8+~0&>vHh6npqsA52po5?6M$e37=q z+AVLN`;VnOvT3S@csc-M>ldICF`ArPPSwxj-j{;^Bucy4c+M#NK#Z(wq=D_&quDA* zlTYJmgXy?nPLJOjj4^hD;eG?h@U|wl72fP^W%t4eg<-kBh1EKJY3t(gFvM$AMA_NE z-q`!GD8={sW!JOY?0UaAk^79`qAmg!ZS3o~M){$ST{5yfU+nn4^Hc0~Mv$URC!Rom z2W-wAplUiG_Q&++uu^t|r&TEK6}O-X6jn2<)!$e>4d(Yh=7~q~^KOTT=lOn)GB%qR zNE@YOP-k(9oVlM==ql?t-ovZ)yTz_8&wD&z?MBCDMbPjM+sk!VSZRnVyk`*fq%HLf z9_ngOXzOJh0%$fMboDBFwH`m#m9RAy_# znD4oi0=yuA(=ix^`b2+(dIpvO@Q`zqrO`2`|FU$`vUStgsXluvBYO|FFOp$7-Q?)& z1iP8T2w4iX)J{+Z>_+IWq~cJnf0D&e4hpB3sI3Ck{F^XJ*3$vM1tKTs*X%vsc+!8I zkKNa%p7>P&lMImo=PYX?|1p^4mT6k%Ny0i-jXp`jA@4Mtbbk!^1BY=ZWGtYH`;elg z61pqdkzD(QrBj9qa(n7@>=Ec_G%c%G*Vemgz(>GP_(;Mrw1#nxW3e5J z-Jkp|Z(lN4kUD=rTqOQEZJt<$C7!J2xSu8ZedPKNn4}M3O<)ct|KLih4wvqv^+7|M zQGMueKQqxIH>Ij&RoFV(S^h6Ja~K>Olct8XBJh_zj^HT1@lx-bQ@Vl}SIf#Je4f)! z>*ABMqU^WqpLl3AkpD-WJx6s&r`Xr2b1khDySDrOj9M-yw3eLD2tXrI?tmTB8;?9q%pAf2*niobIO71I}%L z80ztH*e6kwunVXL!8>D*REQ;dvKG@2R6jl42;d*JM_{VIr3|~(V}gF_3x{$te`PuC zCArzL+(~UpTWHm+;f}=Hm-A#9v3dh>?)UBJW2mdkG;Q(l_UuS$X`q<&AYR?lP{{5^ z8K)c<7RQ|@m8i`4wi@r-BwS5grFB`($W+x>c@iE`eV)?pNwPf9?-3~WK>3u?jML;= z26?o@_#DWmeA?`pK-~;+^Zc!^{_ALSKbdQoz!CL2^7W#{*%Z(G#JIo6PSUJ8@UD9E z9sFZa9H?D5C1^HhTNfEMfdd&@GSp{Q#1S-qYvYnFu zMCqvLk>N=N`*Y7rszn+HoD&s=5wXZB_3x

TTbM%8xOR>yFSY&c={&ZE-p#fHt|f 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 CYct3I diff --git a/src/factorminer/output/mining_batches.jsonl b/src/factorminer/output/mining_batches.jsonl deleted file mode 100644 index 085834f..0000000 --- a/src/factorminer/output/mining_batches.jsonl +++ /dev/null @@ -1,58 +0,0 @@ -{"iteration": 1, "timestamp": 1771372146.9340858, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.2718839826839827, "max_correlation": 0.3224372294372294, "elapsed_seconds": 0.9385337829589844, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.94, "wall_elapsed_seconds": 0.94}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1771372155.33286, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.29021341991342, "max_correlation": 0.345982683982684, "elapsed_seconds": 0.1046302318572998, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.1, "wall_elapsed_seconds": 0.1}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1771372191.49147, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.28298744588744584, "max_correlation": 0.32113419913419905, "elapsed_seconds": 0.10761785507202148, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.11, "wall_elapsed_seconds": 0.11}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1771374094.225513, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.28161515151515154, "max_correlation": 0.3172380952380952, "elapsed_seconds": 0.10254192352294922, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.1, "wall_elapsed_seconds": 0.1}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1771374983.492411, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 10, "replaced": 0, "yield_rate": 1.0, "library_size": 10, "avg_correlation": 0.0826518337571923, "max_correlation": 0.08861830786094034, "elapsed_seconds": 5.175100088119507, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 5.18, "wall_elapsed_seconds": 5.18}} -{"iteration": 2, "timestamp": 1771374997.552633, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 10, "replaced": 0, "yield_rate": 1.0, "library_size": 20, "avg_correlation": 0.0819226296246593, "max_correlation": 0.08903111881751094, "elapsed_seconds": 14.059232950210571, "budget": {"llm_calls": 2, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 19.23, "wall_elapsed_seconds": 19.24}} -{"iteration": 1, "timestamp": 1771403934.5850291, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.29101948051948046, "max_correlation": 0.3218744588744588, "elapsed_seconds": 0.11554503440856934, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.12, "wall_elapsed_seconds": 0.12}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1771405061.187912, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.2739532467532467, "max_correlation": 0.30174025974025964, "elapsed_seconds": 0.11654281616210938, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.12, "wall_elapsed_seconds": 0.12}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773477526.066545, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.2623346320346321, "max_correlation": 0.298030303030303, "elapsed_seconds": 0.10055804252624512, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.1, "wall_elapsed_seconds": 0.1}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773477755.797867, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 10, "replaced": 0, "yield_rate": 1.0, "library_size": 10, "avg_correlation": 0.08229167554572003, "max_correlation": 0.08785380491108624, "elapsed_seconds": 5.2690980434417725, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 5.27, "wall_elapsed_seconds": 5.27}} -{"iteration": 2, "timestamp": 1773477770.4745789, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 10, "replaced": 0, "yield_rate": 1.0, "library_size": 20, "avg_correlation": 0.08181339056591728, "max_correlation": 0.08946456353175042, "elapsed_seconds": 14.67562198638916, "budget": {"llm_calls": 2, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 19.94, "wall_elapsed_seconds": 19.95}} -{"iteration": 1, "timestamp": 1773479130.180734, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.27502251082251084, "max_correlation": 0.321952380952381, "elapsed_seconds": 0.12020707130432129, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.12, "wall_elapsed_seconds": 0.12}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773479165.669386, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.2691047619047619, "max_correlation": 0.339073593073593, "elapsed_seconds": 0.10429883003234863, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.1, "wall_elapsed_seconds": 0.1}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773479410.3163462, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 2, "admitted": 8, "replaced": 0, "yield_rate": 0.8, "library_size": 8, "avg_correlation": 0.10182961052044943, "max_correlation": 0.39296715271527166, "elapsed_seconds": 3.6131629943847656, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 3.61, "wall_elapsed_seconds": 3.61}} -{"iteration": 2, "timestamp": 1773479422.732697, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 8, "replaced": 0, "yield_rate": 0.8, "library_size": 16, "avg_correlation": 0.10055403379740888, "max_correlation": 0.49241112298602563, "elapsed_seconds": 12.328114032745361, "budget": {"llm_calls": 2, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 15.94, "wall_elapsed_seconds": 16.03}} -{"iteration": 3, "timestamp": 1773479440.960487, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 0.5, "library_size": 21, "avg_correlation": 0.0993884563672103, "max_correlation": 0.49241112298602563, "elapsed_seconds": 18.014026880264282, "budget": {"llm_calls": 3, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 33.96, "wall_elapsed_seconds": 34.26}} -{"iteration": 1, "timestamp": 1773836748.845491, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.2711519480519481, "max_correlation": 0.29558441558441567, "elapsed_seconds": 0.17888092994689941, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.18, "wall_elapsed_seconds": 0.18}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773837238.965058, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.27457012987012985, "max_correlation": 0.3106103896103896, "elapsed_seconds": 3.1231608390808105, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 3.12, "wall_elapsed_seconds": 3.12}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773837323.0524108, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.28737835497835496, "max_correlation": 0.3277012987012987, "elapsed_seconds": 1.5550920963287354, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 1.56, "wall_elapsed_seconds": 1.57}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773837412.592681, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.2677051948051948, "max_correlation": 0.32196103896103895, "elapsed_seconds": 0.8609669208526611, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.86, "wall_elapsed_seconds": 0.86}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773837629.6556869, "candidates": 1, "parse_ok": 1, "ic_passed": 1, "corr_passed": 1, "dedup_rejected": 0, "admitted": 1, "replaced": 0, "yield_rate": 1.0, "library_size": 1, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.42867302894592285, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.43, "wall_elapsed_seconds": 0.43}} -{"iteration": 1, "timestamp": 1773837635.175166, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.2859069264069265, "max_correlation": 0.31809090909090904, "elapsed_seconds": 0.7452428340911865, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.75, "wall_elapsed_seconds": 0.75}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773837635.848904, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 4, "replaced": 0, "yield_rate": 0.8, "library_size": 4, "avg_correlation": 0.27866378066378067, "max_correlation": 0.310995670995671, "elapsed_seconds": 0.6553909778594971, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.66, "wall_elapsed_seconds": 0.66}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 1} -{"iteration": 1, "timestamp": 1773837779.205347, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 1.0, "library_size": 5, "avg_correlation": 0.27959134199134195, "max_correlation": 0.32299999999999995, "elapsed_seconds": 0.5378410816192627, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.54, "wall_elapsed_seconds": 0.54}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773837779.8627899, "candidates": 5, "parse_ok": 5, "ic_passed": 5, "corr_passed": 5, "dedup_rejected": 0, "admitted": 4, "replaced": 0, "yield_rate": 0.8, "library_size": 4, "avg_correlation": 0.28019336219336216, "max_correlation": 0.32299999999999995, "elapsed_seconds": 0.6378562450408936, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.64, "wall_elapsed_seconds": 0.64}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 1} -{"iteration": 1, "timestamp": 1773837972.704556, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 2, "admitted": 8, "replaced": 0, "yield_rate": 0.8, "library_size": 8, "avg_correlation": 0.10182961052044943, "max_correlation": 0.39296715271527166, "elapsed_seconds": 4.576587915420532, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 4.58, "wall_elapsed_seconds": 4.58}} -{"iteration": 1, "timestamp": 1773837985.0069509, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 2, "admitted": 8, "replaced": 0, "yield_rate": 0.8, "library_size": 8, "avg_correlation": 0.10182961052044943, "max_correlation": 0.39296715271527166, "elapsed_seconds": 5.351720809936523, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 5.35, "wall_elapsed_seconds": 5.35}} -{"iteration": 1, "timestamp": 1773837986.555286, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 2, "admitted": 8, "replaced": 0, "yield_rate": 0.8, "library_size": 8, "avg_correlation": 0.10182961052044943, "max_correlation": 0.39296715271527166, "elapsed_seconds": 5.52150297164917, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 5.52, "wall_elapsed_seconds": 5.52}} -{"iteration": 2, "timestamp": 1773837987.734293, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 8, "replaced": 0, "yield_rate": 0.8, "library_size": 16, "avg_correlation": 0.10055403379740888, "max_correlation": 0.49241112298602563, "elapsed_seconds": 14.922637224197388, "budget": {"llm_calls": 2, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 19.5, "wall_elapsed_seconds": 19.61}} -{"iteration": 2, "timestamp": 1773837999.707036, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 8, "replaced": 0, "yield_rate": 0.8, "library_size": 16, "avg_correlation": 0.10055403379740888, "max_correlation": 0.49241112298602563, "elapsed_seconds": 14.579509973526001, "budget": {"llm_calls": 2, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 19.93, "wall_elapsed_seconds": 20.05}} -{"iteration": 2, "timestamp": 1773838000.9728808, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 8, "replaced": 0, "yield_rate": 0.8, "library_size": 16, "avg_correlation": 0.10055403379740888, "max_correlation": 0.49241112298602563, "elapsed_seconds": 14.306457757949829, "budget": {"llm_calls": 2, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 19.83, "wall_elapsed_seconds": 19.94}} -{"iteration": 3, "timestamp": 1773838013.123701, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 0.5, "library_size": 21, "avg_correlation": 0.0993884563672103, "max_correlation": 0.49241112298602563, "elapsed_seconds": 25.190452098846436, "budget": {"llm_calls": 3, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 44.69, "wall_elapsed_seconds": 45.0}} -{"iteration": 3, "timestamp": 1773838027.95429, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 0.5, "library_size": 21, "avg_correlation": 0.0993884563672103, "max_correlation": 0.49241112298602563, "elapsed_seconds": 28.030009031295776, "budget": {"llm_calls": 3, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 47.96, "wall_elapsed_seconds": 48.3}} -{"iteration": 3, "timestamp": 1773838028.987365, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 0.5, "library_size": 21, "avg_correlation": 0.0993884563672103, "max_correlation": 0.49241112298602563, "elapsed_seconds": 27.80145001411438, "budget": {"llm_calls": 3, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 47.63, "wall_elapsed_seconds": 47.95}} -{"iteration": 1, "timestamp": 1773838029.7641861, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 2, "admitted": 8, "replaced": 0, "yield_rate": 0.8, "library_size": 8, "avg_correlation": 0.10182961052044943, "max_correlation": 0.39296715271527166, "elapsed_seconds": 4.989582777023315, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 4.99, "wall_elapsed_seconds": 5.0}} -{"iteration": 2, "timestamp": 1773838041.6156852, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 8, "replaced": 0, "yield_rate": 0.8, "library_size": 16, "avg_correlation": 0.10055403379740888, "max_correlation": 0.49241112298602563, "elapsed_seconds": 11.764180183410645, "budget": {"llm_calls": 2, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 16.75, "wall_elapsed_seconds": 16.85}} -{"iteration": 3, "timestamp": 1773838057.484868, "candidates": 10, "parse_ok": 10, "ic_passed": 10, "corr_passed": 10, "dedup_rejected": 0, "admitted": 5, "replaced": 0, "yield_rate": 0.5, "library_size": 21, "avg_correlation": 0.0993884563672103, "max_correlation": 0.49241112298602563, "elapsed_seconds": 15.708050966262817, "budget": {"llm_calls": 3, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 32.46, "wall_elapsed_seconds": 32.72}} -{"iteration": 1, "timestamp": 1773838940.298435, "candidates": 5, "parse_ok": 5, "ic_passed": 1, "corr_passed": 1, "dedup_rejected": 0, "admitted": 1, "replaced": 0, "yield_rate": 0.2, "library_size": 1, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.016602039337158203, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.02, "wall_elapsed_seconds": 0.02}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773838940.320651, "candidates": 5, "parse_ok": 5, "ic_passed": 1, "corr_passed": 1, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.01556396484375, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.02, "wall_elapsed_seconds": 0.02}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 1} -{"iteration": 1, "timestamp": 1773838994.73529, "candidates": 5, "parse_ok": 5, "ic_passed": 1, "corr_passed": 1, "dedup_rejected": 0, "admitted": 1, "replaced": 0, "yield_rate": 0.2, "library_size": 1, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.015350103378295898, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.02, "wall_elapsed_seconds": 0.02}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773838994.7518551, "candidates": 5, "parse_ok": 5, "ic_passed": 1, "corr_passed": 1, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.012424945831298828, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 1} -{"iteration": 1, "timestamp": 1773855583.600068, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.010687828063964844, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773855583.613107, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.009007930755615234, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773855659.131016, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.011964797973632812, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773855659.143899, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.009601831436157227, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773920133.865679, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.008460760116577148, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773920133.876478, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.007336854934692383, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773920147.186651, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.008671045303344727, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773920147.1967309, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.0077440738677978516, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773920686.632258, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.009449243545532227, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773920686.642327, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.007815122604370117, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773920840.751235, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.008711099624633789, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773920840.763021, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.008273839950561523, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 1, "timestamp": 1773921057.33887, "candidates": 10, "parse_ok": 10, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 1.32820725440979, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 1.33, "wall_elapsed_seconds": 1.33}} -{"iteration": 2, "timestamp": 1773921058.692768, "candidates": 10, "parse_ok": 10, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 1.3515691757202148, "budget": {"llm_calls": 2, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 2.68, "wall_elapsed_seconds": 2.68}} -{"iteration": 3, "timestamp": 1773921060.054324, "candidates": 10, "parse_ok": 10, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 1.360398769378662, "budget": {"llm_calls": 3, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 4.04, "wall_elapsed_seconds": 4.04}} -{"iteration": 1, "timestamp": 1773921156.6428359, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.008219003677368164, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} -{"iteration": 0, "timestamp": 1773921156.652971, "candidates": 5, "parse_ok": 5, "ic_passed": 0, "corr_passed": 0, "dedup_rejected": 0, "admitted": 0, "replaced": 0, "yield_rate": 0.0, "library_size": 0, "avg_correlation": 0.0, "max_correlation": 0.0, "elapsed_seconds": 0.007454872131347656, "budget": {"llm_calls": 1, "llm_prompt_tokens": 0, "llm_completion_tokens": 0, "total_tokens": 0, "compute_seconds": 0.01, "wall_elapsed_seconds": 0.01}, "candidates_before_canon": 5, "canonical_duplicates_removed": 0, "phase2_rejections": 0} diff --git a/src/factorminer/output/session.json b/src/factorminer/output/session.json deleted file mode 100644 index cb154c4..0000000 --- a/src/factorminer/output/session.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "session_id": "20260319_125236", - "config": { - "target_library_size": 3, - "batch_size": 5, - "max_iterations": 1, - "ic_threshold": 0.04, - "icir_threshold": 0.5, - "correlation_threshold": 0.5, - "replacement_ic_min": 0.1, - "replacement_ic_ratio": 1.3, - "fast_screen_assets": 100, - "num_workers": 40, - "output_dir": "./output", - "gpu_device": "cuda:0", - "backend": "numpy", - "signal_failure_policy": "reject" - }, - "output_dir": "./output", - "start_time": "2026-03-19T12:52:36.633890", - "end_time": "2026-03-19T12:52:36.644699", - "status": "completed", - "total_iterations": 1, - "last_library_size": 0, - "library_path": "output/checkpoint/library", - "memory_path": "output/checkpoint/memory.json", - "iterations": [ - { - "iteration": 1, - "candidates": 5, - "parse_ok": 5, - "ic_passed": 0, - "corr_passed": 0, - "dedup_rejected": 0, - "admitted": 0, - "replaced": 0, - "yield_rate": 0.0, - "library_size": 0, - "avg_correlation": 0.0, - "max_correlation": 0.0, - "elapsed_seconds": 0.008219003677368164, - "budget": { - "llm_calls": 1, - "llm_prompt_tokens": 0, - "llm_completion_tokens": 0, - "total_tokens": 0, - "compute_seconds": 0.01, - "wall_elapsed_seconds": 0.01 - }, - "candidates_before_canon": 5, - "canonical_duplicates_removed": 0, - "phase2_rejections": 0, - "timestamp": "2026-03-19T12:52:36.643364" - } - ] -} \ No newline at end of file diff --git a/src/factorminer/output/session_log.json b/src/factorminer/output/session_log.json deleted file mode 100644 index d7ec7a8..0000000 --- a/src/factorminer/output/session_log.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "iterations": [ - { - "iteration": 1, - "candidates_generated": 5, - "ic_passed": 0, - "correlation_passed": 0, - "admitted": 0, - "rejected": 5, - "replaced": 0, - "library_size": 0, - "best_ic": 0.2486060606060606, - "mean_ic": 0.049721212121212124, - "elapsed_seconds": 0.008219003677368164, - "timestamp": 1773921156.643179, - "yield_rate": 0.0 - } - ], - "factors": [ - { - "expression": "Neg(CsRank(Delta($close, 5)))", - "ic": 0.0, - "icir": 0.0, - "max_correlation": 0.0, - "admitted": false, - "rejection_reason": "Signal computation error: Expression evaluation failed for 'Neg(CsRank(Delta($close, 5)))': \"Feature '$close' not found in data. Available: ['$high', '$low', '$open']\"", - "timestamp": 1773921156.64333 - }, - { - "expression": "CsZScore(Div(Sub($volume, Mean($volume, 20)), Std($volume, 20)))", - "ic": 0.0, - "icir": 0.0, - "max_correlation": 0.0, - "admitted": false, - "rejection_reason": "Signal computation error: Expression evaluation failed for 'CsZScore(Div(Sub($volume, Mean($volume, 20)), Std($volume, 20)))': \"Feature '$volume' not found in data. Available: ['$high', '$low', '$open']\"", - "timestamp": 1773921156.6433449 - }, - { - "expression": "Div(Sub($high, $low), Add($high, $low))", - "ic": 0.2486060606060606, - "icir": -0.3678513450563207, - "max_correlation": 0.0, - "admitted": false, - "rejection_reason": "ICIR -0.3679 < threshold 0.5", - "timestamp": 1773921156.643349 - }, - { - "expression": "CsRank(Div(Sub($close, $vwap), $vwap))", - "ic": 0.0, - "icir": 0.0, - "max_correlation": 0.0, - "admitted": false, - "rejection_reason": "Signal computation error: Expression evaluation failed for 'CsRank(Div(Sub($close, $vwap), $vwap))': \"Feature '$close' not found in data. Available: ['$high', '$low', '$open']\"", - "timestamp": 1773921156.643352 - }, - { - "expression": "Neg(Skew($returns, 20))", - "ic": 0.0, - "icir": 0.0, - "max_correlation": 0.0, - "admitted": false, - "rejection_reason": "Signal computation error: Expression evaluation failed for 'Neg(Skew($returns, 20))': \"Feature '$returns' not found in data. Available: ['$high', '$low', '$open']\"", - "timestamp": 1773921156.6433542 - } - ], - "summary": { - "total_iterations": 1, - "total_candidates": 5, - "total_admitted": 0, - "overall_yield_rate": 0.0, - "final_library_size": 0 - } -} \ No newline at end of file diff --git a/src/factorminer/run_demo.py b/src/factorminer/run_demo.py deleted file mode 100644 index 895b00b..0000000 --- a/src/factorminer/run_demo.py +++ /dev/null @@ -1,517 +0,0 @@ -#!/usr/bin/env python3 -"""HelixFactor End-to-End Demo - -Demonstrates the complete system on synthetic data: -1. Generate realistic mock market data with planted alpha -2. Evaluate the paper's 110 factors on this data -3. Run the mining loop with MockProvider -4. Show factor combination and selection -5. Demonstrate Phase 2 features (causal, regime, significance, canonicalization) - -No API keys needed - uses MockProvider for LLM generation. -""" - -import sys -import time -import warnings - -warnings.filterwarnings("ignore") - -sys.path.insert(0, ".") -import numpy as np - -np.random.seed(42) - -from src.factorminer.data.mock_data import generate_mock_data, MockConfig -from src.factorminer.data.preprocessor import preprocess -from src.factorminer.core.parser import parse, try_parse -from src.factorminer.core.factor_library import Factor, FactorLibrary -from src.factorminer.core.library_io import PAPER_FACTORS -from src.factorminer.evaluation.metrics import ( - compute_ic, - compute_icir, - compute_ic_mean, - compute_ic_win_rate, - compute_factor_stats, -) -from src.factorminer.evaluation.combination import FactorCombiner -from src.factorminer.evaluation.selection import FactorSelector - - -def section(title): - print(f"\n{'=' * 70}") - print(f" {title}") - print(f"{'=' * 70}\n") - - -def main(): - # ================================================================ - # STEP 1: Generate Mock Market Data - # ================================================================ - section("STEP 1: Generate Mock Market Data") - - config = MockConfig( - num_assets=100, - num_periods=500, # ~21 trading days of 10-min bars - frequency="10min", - plant_alpha=True, - alpha_strength=0.03, - alpha_assets_frac=0.3, - seed=42, - ) - raw_data = generate_mock_data(config) - print(f" Generated: {raw_data.shape[0]:,} rows") - print(f" Assets: {raw_data['asset_id'].nunique()}") - print(f" Periods: {raw_data.groupby('asset_id').size().iloc[0]}") - print(f" Columns: {list(raw_data.columns)}") - print(f" Date range: {raw_data['datetime'].min()} to {raw_data['datetime'].max()}") - - # Preprocess - processed = preprocess(raw_data) - print(f" After preprocessing: {processed.shape[0]:,} rows") - - # Build data dict for expression tree evaluation - assets = sorted(processed["asset_id"].unique()) - M = len(assets) - T = processed.groupby("asset_id").size().min() - - # Pivot to (M, T) arrays - data_dict = {} - feature_map = { - "$open": "open", - "$high": "high", - "$low": "low", - "$close": "close", - "$volume": "volume", - "$amt": "amount", - "$vwap": "vwap", - "$returns": "returns", - } - for feat_name, col_name in feature_map.items(): - if col_name in processed.columns: - pivot = processed.pivot( - index="asset_id", columns="datetime", values=col_name - ) - pivot = pivot.loc[assets].iloc[:, :T] - data_dict[feat_name] = pivot.values.astype(np.float64) - - # Compute forward returns (target) - close = data_dict["$close"] - forward_returns = np.roll(close, -1, axis=1) / close - 1 - forward_returns[:, -1] = np.nan # last period unknown - - print(f" Data tensor: M={M} assets, T={T} periods, F={len(data_dict)} features") - print(f" Forward returns: shape={forward_returns.shape}") - - # ================================================================ - # STEP 2: Evaluate Paper's 110 Factors - # ================================================================ - section("STEP 2: Evaluate Paper's 110 Factors on Mock Data") - - results = [] - parse_failures = 0 - eval_failures = 0 - - t0 = time.time() - for idx, factor_info in enumerate(PAPER_FACTORS): - fid = idx + 1 - fname = factor_info["name"] - formula = factor_info["formula"] - category = factor_info["category"] - - tree = try_parse(formula) - if tree is None: - parse_failures += 1 - continue - - try: - signals = tree.evaluate(data_dict) - ic_series = compute_ic(signals, forward_returns) - ic_mean = compute_ic_mean(ic_series) - icir = compute_icir(ic_series) - win_rate = compute_ic_win_rate(ic_series) - - results.append( - { - "id": fid, - "name": fname, - "formula": formula, - "category": category, - "ic_mean": ic_mean, - "icir": icir, - "win_rate": win_rate, - "signals": signals, - "ic_series": ic_series, - } - ) - except Exception: - eval_failures += 1 - - elapsed = time.time() - t0 - print(f" Evaluated {len(results)} factors in {elapsed:.1f}s") - print(f" Parse failures: {parse_failures}, Eval failures: {eval_failures}") - - # Sort by |IC| - results.sort(key=lambda x: abs(x["ic_mean"]), reverse=True) - - print(f"\n Top 20 Factors by |IC|:") - print(f" {'ID':<5} {'Name':<40} {'Cat':<15} {'IC':>8} {'ICIR':>8} {'Win%':>6}") - print(f" {'-' * 5} {'-' * 40} {'-' * 15} {'-' * 8} {'-' * 8} {'-' * 6}") - for r in results[:20]: - print( - f" {r['id']:<5} {r['name'][:40]:<40} {r['category'][:15]:<15} " - f"{r['ic_mean']:>8.4f} {r['icir']:>8.3f} {r['win_rate']:>5.1%}" - ) - - # Category breakdown - print(f"\n Category Breakdown:") - categories = {} - for r in results: - cat = r["category"] - if cat not in categories: - categories[cat] = [] - categories[cat].append(abs(r["ic_mean"])) - for cat, ics in sorted(categories.items(), key=lambda x: -np.mean(x[1])): - print(f" {cat:<25} {len(ics):>3} factors avg|IC|={np.mean(ics):.4f}") - - # ================================================================ - # STEP 3: Build Factor Library with Admission Rules - # ================================================================ - section("STEP 3: Build Factor Library (IC > 0.02, corr < 0.5)") - - library = FactorLibrary(correlation_threshold=0.5, ic_threshold=0.02) - admitted = 0 - rejected_ic = 0 - rejected_corr = 0 - - for r in results: - ic_abs = abs(r["ic_mean"]) - if ic_abs < 0.02: - rejected_ic += 1 - continue - - # Check correlation with existing library - can_admit = True - max_corr = 0.0 - for existing_id, existing_factor in library.factors.items(): - if existing_factor.signals is not None: - corr = library.compute_correlation( - r["signals"], existing_factor.signals - ) - max_corr = max(max_corr, abs(corr)) - if abs(corr) >= 0.5: - can_admit = False - break - - if not can_admit: - rejected_corr += 1 - continue - - factor = Factor( - id=library._next_id, - name=r["name"], - formula=r["formula"], - category=r["category"], - ic_mean=r["ic_mean"], - icir=r["icir"], - ic_win_rate=r["win_rate"], - max_correlation=max_corr, - batch_number=1, - admission_date="2024-01-01", - signals=r["signals"], - ) - library.admit_factor(factor) - admitted += 1 - - print(f" Admitted: {admitted}") - print(f" Rejected (IC < 0.02): {rejected_ic}") - print(f" Rejected (correlation >= 0.5): {rejected_corr}") - print(f" Library size: {library.size}") - - if library.size > 0: - diag = library.get_diagnostics() - print(f" Avg |rho|: {diag.get('avg_correlation', 0):.4f}") - - # ================================================================ - # STEP 4: Factor Combination - # ================================================================ - if library.size >= 3: - section("STEP 4: Factor Combination Methods") - - factor_signals = {} - ic_values = {} - for fid, factor in library.factors.items(): - if factor.signals is not None: - factor_signals[fid] = factor.signals - ic_values[fid] = factor.ic_mean - - combiner = FactorCombiner() - - # Equal weight - ew = combiner.equal_weight(factor_signals) - ew_ic = compute_ic(ew, forward_returns) - print( - f" Equal-Weight: IC={compute_ic_mean(ew_ic):.4f}, " - f"ICIR={compute_icir(ew_ic):.3f}, " - f"Win={compute_ic_win_rate(ew_ic):.1%}" - ) - - # IC-weighted - icw = combiner.ic_weighted(factor_signals, ic_values) - icw_ic = compute_ic(icw, forward_returns) - print( - f" IC-Weighted: IC={compute_ic_mean(icw_ic):.4f}, " - f"ICIR={compute_icir(icw_ic):.3f}, " - f"Win={compute_ic_win_rate(icw_ic):.1%}" - ) - - # Orthogonal - try: - ortho = combiner.orthogonal(factor_signals) - ortho_ic = compute_ic(ortho, forward_returns) - print( - f" Orthogonal: IC={compute_ic_mean(ortho_ic):.4f}, " - f"ICIR={compute_icir(ortho_ic):.3f}, " - f"Win={compute_ic_win_rate(ortho_ic):.1%}" - ) - except Exception as e: - print(f" Orthogonal: skipped ({e})") - - # ================================================================ - # STEP 5: Phase 2 - Regime Detection - # ================================================================ - section("STEP 5: Phase 2 - Regime-Aware Analysis") - - from src.factorminer.evaluation.regime import ( - RegimeDetector, - RegimeAwareEvaluator, - RegimeConfig, - ) - - regime_config = RegimeConfig( - lookback_window=30, min_regime_ic=0.01, min_regimes_passing=2 - ) - detector = RegimeDetector(regime_config) - classification = detector.classify(forward_returns) - - from src.factorminer.evaluation.regime import MarketRegime - - for regime in MarketRegime: - mask = classification.periods[regime] - n = int(mask.sum()) - stats = classification.stats[regime] - print( - f" {regime.value:>10}: {n:>4} periods " - f"(avg_ret={stats['mean_return']:.4f}, vol={stats['volatility']:.4f})" - ) - - if library.size > 0: - evaluator = RegimeAwareEvaluator(forward_returns, classification, regime_config) - best_factor = list(library.factors.values())[0] - if best_factor.signals is not None: - regime_result = evaluator.evaluate(best_factor.name, best_factor.signals) - print(f"\n Top factor '{best_factor.name}' regime analysis:") - for regime, ic_val in regime_result.regime_ic.items(): - print(f" {regime.value:>10}: IC={ic_val:.4f}") - print( - f" Regimes passing: {regime_result.n_regimes_passing}, Passes: {regime_result.passes}" - ) - - # ================================================================ - # STEP 6: Phase 2 - Statistical Significance - # ================================================================ - section("STEP 6: Phase 2 - Statistical Significance Testing") - - from src.factorminer.evaluation.significance import ( - BootstrapICTester, - FDRController, - DeflatedSharpeCalculator, - SignificanceConfig, - ) - - sig_config = SignificanceConfig(bootstrap_n_samples=500, bootstrap_block_size=10) - bootstrap = BootstrapICTester(sig_config) - fdr = FDRController(sig_config) - - if library.size > 0: - # Bootstrap CI for top 5 factors - print(" Bootstrap 95% CI for top factors:") - p_values = {} - for fid, factor in list(library.factors.items())[:5]: - ic_series = compute_ic(factor.signals, forward_returns) - ci = bootstrap.compute_ci(factor.name, ic_series) - p_val = bootstrap.compute_p_value(ic_series) - p_values[factor.name] = p_val - print( - f" {factor.name[:35]:<35} IC={ci.ic_mean:.4f} " - f"CI=[{ci.ci_lower:.4f}, {ci.ci_upper:.4f}] " - f"{'*' if ci.ci_excludes_zero else ' '} p={p_val:.4f}" - ) - - # FDR correction - if len(p_values) >= 2: - fdr_result = fdr.apply_fdr(p_values) - print(f"\n FDR Correction (BH at {sig_config.fdr_level}):") - print( - f" Significant discoveries: {fdr_result.n_discoveries}/{len(p_values)}" - ) - - # ================================================================ - # STEP 7: Phase 2 - SymPy Canonicalization - # ================================================================ - section("STEP 7: Phase 2 - SymPy Formula Canonicalization") - - from src.factorminer.core.canonicalizer import FormulaCanonicalizer - - canon = FormulaCanonicalizer() - - test_pairs = [ - ("Neg(Neg($close))", "$close", True), - ("Add($close, $open)", "Add($open, $close)", True), - ("CsRank(Neg($close))", "Neg(CsRank($close))", False), - ("Mul($close, Div($open, $close))", "$open", True), - ] - - print(" Equivalence Detection:") - for f1, f2, expected in test_pairs: - t1 = try_parse(f1) - t2 = try_parse(f2) - if t1 and t2: - h1 = canon.canonicalize(t1) - h2 = canon.canonicalize(t2) - is_dup = h1 == h2 - status = "CORRECT" if is_dup == expected else "WRONG" - sym = "==" if is_dup else "!=" - print(f" {f1:>35} {sym} {f2:<35} [{status}]") - - # ================================================================ - # STEP 8: Phase 2 - Knowledge Graph - # ================================================================ - section("STEP 8: Phase 2 - Knowledge Graph Memory") - - from src.factorminer.memory.knowledge_graph import FactorKnowledgeGraph, FactorNode - import re - - kg = FactorKnowledgeGraph() - - for fid, factor in list(library.factors.items())[:20]: - operators = re.findall(r"([A-Z][a-zA-Z]+)\(", factor.formula) - features = re.findall(r"\$[a-z]+", factor.formula) - node = FactorNode( - factor_id=str(fid), - formula=factor.formula, - ic_mean=factor.ic_mean, - category=factor.category, - operators=list(set(operators)), - features=list(set(features)), - batch_number=1, - admitted=True, - ) - kg.add_factor(node) - - # Add correlation edges - factor_list = list(library.factors.values())[:20] - for i in range(len(factor_list)): - for j in range(i + 1, len(factor_list)): - if ( - factor_list[i].signals is not None - and factor_list[j].signals is not None - ): - corr = library.compute_correlation( - factor_list[i].signals, factor_list[j].signals - ) - if abs(corr) > 0.3: - kg.add_correlation_edge( - str(factor_list[i].id), - str(factor_list[j].id), - abs(corr), - threshold=0.3, - ) - - print( - f" Knowledge Graph: {kg.get_factor_count()} factor nodes, {kg.get_edge_count()} edges" - ) - - saturated = kg.find_saturated_regions(threshold=0.3) - print(f" Saturated clusters (rho > 0.3): {len(saturated)}") - for i, cluster in enumerate(saturated[:3]): - print(f" Cluster {i + 1}: {len(cluster)} factors") - - cooccur = kg.get_operator_cooccurrence() - if cooccur: - top_pairs = sorted(cooccur.items(), key=lambda x: -x[1])[:5] - print(f" Top operator co-occurrences:") - for (op1, op2), count in top_pairs: - print(f" ({op1}, {op2}): {count} times") - - # ================================================================ - # STEP 9: Mining Loop Demo (3 iterations) - # ================================================================ - section("STEP 9: Mining Loop Demo (3 iterations with MockProvider)") - - from src.factorminer.core.ralph_loop import RalphLoop - from src.factorminer.agent.llm_interface import MockProvider - from src.factorminer.core.config import MiningConfig - - mining_config = MiningConfig( - target_library_size=20, - batch_size=10, - max_iterations=3, - ic_threshold=0.02, - correlation_threshold=0.5, - fast_screen_assets=50, - num_workers=1, - signal_failure_policy="synthetic", - ) - - loop = RalphLoop( - config=mining_config, - data_tensor=np.stack(list(data_dict.values()), axis=-1), # (M, T, F) - returns=forward_returns, - llm_provider=MockProvider(), - ) - - print(" Running 3 mining iterations...") - t0 = time.time() - result_library = loop.run(target_size=20, max_iterations=3) - elapsed = time.time() - t0 - - print(f" Completed in {elapsed:.1f}s") - print(f" Library size: {result_library.size}") - if result_library.size > 0: - print(f"\n Admitted factors:") - for fid, f in result_library.factors.items(): - print(f" [{fid}] {f.name[:50]} IC={f.ic_mean:.4f} ICIR={f.icir:.3f}") - - # ================================================================ - # SUMMARY - # ================================================================ - section("SUMMARY") - - print(f" Mock data: {M} assets x {T} periods") - print(f" Paper factors evaluated: {len(results)}/110") - print( - f" Factors with |IC| > 0.02: {sum(1 for r in results if abs(r['ic_mean']) > 0.02)}" - ) - print(f" Library (admission-filtered): {library.size} factors") - print(f" Mining loop (3 iter): {result_library.size} factors discovered") - print(f"") - print(f" Phase 2 Features Demonstrated:") - print(f" Regime detection: 3 regimes classified") - print(f" Statistical testing: Bootstrap CI + FDR") - print(f" Canonicalization: Neg(Neg(x))==x detected") - print( - f" Knowledge graph: {kg.get_factor_count()} nodes, {kg.get_edge_count()} edges" - ) - print(f"") - print(f" To run with real LLM (e.g., Claude):") - print(f" export ANTHROPIC_API_KEY=sk-ant-...") - print(f" factorminer mine --config src/factorminer/configs/default.yaml") - print(f"") - print(f" To run with real market data:") - print(f" Place CSV with [datetime,asset_id,open,high,low,close,volume,amount]") - print(f" at data/market.csv and update configs/default.yaml") - - -if __name__ == "__main__": - main() diff --git a/src/factorminer/run_phase2_benchmark.py b/src/factorminer/run_phase2_benchmark.py deleted file mode 100644 index 4ec4ea4..0000000 --- a/src/factorminer/run_phase2_benchmark.py +++ /dev/null @@ -1,803 +0,0 @@ -#!/usr/bin/env python3 -"""HelixFactor Phase 2 Comprehensive Benchmark Runner - -Generates a complete publication-quality benchmarking report comparing -HelixFactor (Phase 2) against FactorMiner (Ralph Loop) and all baselines. - -Usage: - python run_phase2_benchmark.py --mock # quick mock data run - python run_phase2_benchmark.py --mock --n-factors 40 # custom factor count - python run_phase2_benchmark.py --mock --full-ablation # include all ablations - python run_phase2_benchmark.py --data path/to/data.csv # real data - -Outputs (in results/phase2_benchmark/): - benchmark_report.html — full interactive HTML report - benchmark_report.md — GitHub-ready Markdown table - benchmark_report_full.md — narrative markdown report - latex_table.tex — publication LaTeX Table 1 - ablation_table.tex — ablation study LaTeX table - statistical_tests.json — all statistical test results - phase2_manifest.json — machine-readable artifact/provenance manifest - library_metrics.csv — per-method library metrics - combination_metrics.csv — per-method combination metrics - selection_metrics.csv — per-method selection metrics - turnover_metrics.csv — runtime turnover metrics - cost_pressure_metrics.csv — runtime cost-adjusted metrics - runtime_topk.csv — runtime top-k summary - comparison_plot.png — bar chart comparison figure - ablation_contributions.csv — component contribution summary -""" - -from __future__ import annotations - -import argparse -import copy -import hashlib -import json -import logging -import sys -import time -import warnings -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -import numpy as np -import pandas as pd - -warnings.filterwarnings("ignore") - -# Insert the repo root so that direct module imports bypass the package __init__ -# (the package __init__ chains through factorminer.agent which has a known -# import issue with build_critic_scoring_prompt; all benchmark code is -# self-contained in helix_benchmark.py) -_REPO_ROOT = Path(__file__).parent -sys.path.insert(0, str(_REPO_ROOT)) - - -def _load_module_direct(module_name: str, file_path: Path): - """Load a Python module directly from a file path, bypassing package init.""" - import importlib.util - spec = importlib.util.spec_from_file_location(module_name, str(file_path)) - mod = importlib.util.module_from_spec(spec) - # Register in sys.modules so lazy imports inside the module work - sys.modules[module_name] = mod - spec.loader.exec_module(mod) - return mod - - -# --------------------------------------------------------------------------- -# CLI parsing -# --------------------------------------------------------------------------- - -def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description="HelixFactor Phase 2 Comprehensive Benchmark", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - parser.add_argument( - "--mock", action="store_true", - help="Use synthetic mock data (no API keys needed)", - ) - parser.add_argument( - "--data", type=str, default=None, - help="Path to real market data CSV", - ) - parser.add_argument( - "--n-factors", type=int, default=40, - help="Target library size per method", - ) - parser.add_argument( - "--n-assets", type=int, default=100, - help="Number of assets in mock data", - ) - parser.add_argument( - "--n-periods", type=int, default=600, - help="Number of time periods in mock data", - ) - parser.add_argument( - "--output", type=str, default="results/phase2_benchmark", - help="Output directory for results", - ) - parser.add_argument( - "--seed", type=int, default=42, - help="Random seed for reproducibility", - ) - parser.add_argument( - "--methods", nargs="*", default=None, - help="Methods to benchmark (default: all 5)", - ) - parser.add_argument( - "--full-ablation", action="store_true", - help="Run full ablation study (slower)", - ) - parser.add_argument( - "--skip-ablation", action="store_true", - help="Skip ablation study entirely", - ) - parser.add_argument( - "--log-level", type=str, default="WARNING", - help="Logging level", - ) - return parser.parse_args() - - -# --------------------------------------------------------------------------- -# Formatting helpers -# --------------------------------------------------------------------------- - -def _section(title: str) -> None: - bar = "=" * 70 - print(f"\n{bar}") - print(f" {title}") - print(f"{bar}\n") - - -def _subsection(title: str) -> None: - print(f"\n --- {title} ---") - - -def _fmt_pct(v: float) -> str: - return f"{v * 100:.2f}%" - - -def _json_safe(value: Any) -> Any: - """Recursively convert a structure into JSON-safe primitives.""" - if isinstance(value, dict): - return {str(k): _json_safe(v) for k, v in value.items()} - if isinstance(value, list): - return [_json_safe(v) for v in value] - if isinstance(value, tuple): - return [_json_safe(v) for v in value] - if isinstance(value, np.generic): - value = value.item() - if isinstance(value, float) and not np.isfinite(value): - return None - return value - - -def _file_sha256(path: Path) -> str: - digest = hashlib.sha256() - with open(path, "rb") as fp: - for chunk in iter(lambda: fp.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def _load_json(path: Path) -> dict[str, Any] | None: - if not path.exists(): - return None - try: - with open(path) as fp: - payload = json.load(fp) - except Exception as exc: # pragma: no cover - defensive provenance capture - return {"path": str(path), "load_error": str(exc)} - if isinstance(payload, dict): - return payload - return {"path": str(path), "payload_type": type(payload).__name__} - - -def _collect_runtime_manifest_refs(root: Path) -> list[dict[str, Any]]: - if not root.exists(): - return [] - - refs: list[dict[str, Any]] = [] - for manifest_path in sorted(root.rglob("*_manifest.json")): - if manifest_path.name == "phase2_manifest.json": - continue - payload = _load_json(manifest_path) - if payload is None: - continue - - refs.append( - { - "path": str(manifest_path), - "sha256": _file_sha256(manifest_path), - "benchmark_name": payload.get("benchmark_name"), - "baseline": payload.get("baseline"), - "mode": payload.get("mode"), - "artifact_paths": payload.get("artifact_paths", {}), - "baseline_provenance": payload.get("baseline_provenance", {}), - } - ) - return refs - - -def _build_phase2_manifest( - *, - output_dir: Path, - methods: list[str], - seed: int, - n_factors: int, - mock: bool, - data_path: str | None, - full_ablation: bool, - skip_ablation: bool, - artifact_paths: dict[str, str], - statistical_tests: dict[str, Any], - ablation_configs: list[str] | None = None, - runtime_manifest_root: Path | None = None, -) -> dict[str, Any]: - runtime_refs = _collect_runtime_manifest_refs( - runtime_manifest_root or output_dir - ) - return { - "benchmark_name": "phase2", - "output_dir": str(output_dir), - "generated_at": datetime.now(timezone.utc).isoformat(), - "run_parameters": { - "methods": methods, - "seed": seed, - "n_factors": n_factors, - "mock": mock, - "data_path": data_path, - "full_ablation": full_ablation, - "skip_ablation": skip_ablation, - }, - "artifact_paths": artifact_paths, - "statistical_tests": _json_safe(statistical_tests), - "ablation": { - "configs": ablation_configs or [], - }, - "runtime_manifest_root": str(runtime_manifest_root or output_dir), - "runtime_manifest_refs": runtime_refs, - } - - -def _derive_split_periods(raw_df: pd.DataFrame) -> tuple[list[str], list[str]]: - """Derive contiguous train/test periods from the loaded market data.""" - timestamps = pd.to_datetime(raw_df["datetime"]).sort_values().unique() - if len(timestamps) < 2: - raise ValueError("Need at least two timestamps to derive train/test splits") - - split_idx = max(int(len(timestamps) * 0.7), 1) - split_idx = min(split_idx, len(timestamps) - 1) - train_start = pd.Timestamp(timestamps[0]).isoformat() - train_end = pd.Timestamp(timestamps[split_idx - 1]).isoformat() - test_start = pd.Timestamp(timestamps[split_idx]).isoformat() - test_end = pd.Timestamp(timestamps[-1]).isoformat() - return [train_start, train_end], [test_start, test_end] - - -def _runtime_topk_markdown(runtime_artifacts: dict[str, Any]) -> str: - frame = _runtime_topk_frame(runtime_artifacts) - if frame.empty: - return "" - return frame.to_markdown(index=False, floatfmt=".4f") - - -def _runtime_topk_frame(runtime_artifacts: dict[str, Any]) -> pd.DataFrame: - payloads = runtime_artifacts.get("runtime_payloads", {}) - rows = [] - for method, runs in payloads.items(): - if not runs: - continue - topk = runs[0].get("frozen_top_k", []) - for rank, item in enumerate(topk[:10], 1): - rows.append( - { - "method": method, - "rank": rank, - "name": item.get("name", ""), - "train_ic": item.get("train_ic", 0.0), - "train_icir": item.get("train_icir", 0.0), - } - ) - if not rows: - return pd.DataFrame() - return pd.DataFrame(rows) - - -def _print_improvement_table(bench_result) -> None: - """Print clear table showing HelixFactor improvement over FactorMiner.""" - lib = bench_result.factor_library_metrics - comb = bench_result.combination_metrics - sel = bench_result.selection_metrics - - helix_lib = lib[lib["method"] == "helix_phase2"] - ralph_lib = lib[lib["method"] == "ralph_loop"] - helix_comb = comb[comb["method"] == "helix_phase2"] - ralph_comb = comb[comb["method"] == "ralph_loop"] - helix_sel = sel[sel["method"] == "helix_phase2"] - ralph_sel = sel[sel["method"] == "ralph_loop"] - - if helix_lib.empty or ralph_lib.empty: - print(" (Could not compute improvement — method results missing)") - return - - def _get(df, col, default=0.0): - if df.empty or col not in df.columns: - return default - v = df.iloc[0][col] - return float(v) if v == v else default # NaN check - - h_ic = _get(helix_lib, "ic_pct") - r_ic = _get(ralph_lib, "ic_pct") - h_icir = _get(helix_lib, "icir") - r_icir = _get(ralph_lib, "icir") - h_ew = _get(helix_comb, "ew_ic_pct") - r_ew = _get(ralph_comb, "ew_ic_pct") - h_icw = _get(helix_comb, "icw_ic_pct") - r_icw = _get(ralph_comb, "icw_ic_pct") - h_las = _get(helix_sel, "lasso_ic_pct") - r_las = _get(ralph_sel, "lasso_ic_pct") - h_xgb = _get(helix_sel, "xgb_ic_pct") - r_xgb = _get(ralph_sel, "xgb_ic_pct") - - def _delta(h, r): - if r < 1e-8: - return "N/A" - return f"+{(h - r) / r * 100:.1f}%" - - print(f"\n {'Metric':<28} {'FactorMiner':>12} {'HelixFactor':>12} {'Improvement':>12}") - print(f" {'-'*28} {'-'*12} {'-'*12} {'-'*12}") - metrics = [ - ("Library IC (%)", r_ic, h_ic), - ("Library ICIR", r_icir, h_icir), - ("EW Combo IC (%)", r_ew, h_ew), - ("ICW Combo IC (%)", r_icw, h_icw), - ("LASSO Sel IC (%)", r_las, h_las), - ("XGBoost Sel IC (%)", r_xgb, h_xgb), - ] - for name, r_val, h_val in metrics: - print( - f" {name:<28} {r_val:>12.4f} {h_val:>12.4f} {_delta(h_val, r_val):>12}" - ) - - -def _fmt_stat(v, fmt=".4f") -> str: - """Format a stat value, showing N/A for NaN.""" - if v is None: - return "N/A" - try: - f = float(v) - if f != f: # NaN - return "N/A" - return format(f, fmt) - except (TypeError, ValueError): - return str(v) - - -def _print_stat_tests(stat_tests: dict) -> None: - dm = stat_tests.get("diebold_mariano", {}) - boot = stat_tests.get("bootstrap_ci_95", {}) - tt = stat_tests.get("paired_t_test", {}) - wil = stat_tests.get("wilcoxon", {}) - mean_diff = stat_tests.get("mean_ic_difference", 0.0) - - print(f" Mean IC difference (Helix - Ralph): {_fmt_stat(mean_diff, '+.4f')}") - print(f" Helix outperforms: {stat_tests.get('helix_outperforms', '?')}") - print() - - dm_p = dm.get("p_value", float("nan")) - dm_stat_val = dm.get("dm_stat", float("nan")) - try: - sig_dm = " *" if float(dm_p) < 0.05 else "" - except (TypeError, ValueError): - sig_dm = "" - print(f" Diebold-Mariano test:") - print(f" DM statistic = {_fmt_stat(dm_stat_val)}{sig_dm}") - print(f" p-value = {_fmt_stat(dm_p)}") - print(f" Direction = {dm.get('direction', '?')}") - print() - - tt_p = tt.get("p_value", float("nan")) - try: - sig_tt = " *" if float(tt_p) < 0.05 else "" - except (TypeError, ValueError): - sig_tt = "" - print(f" Paired t-test:") - print(f" t-stat = {_fmt_stat(tt.get('t_stat', float('nan')))}{sig_tt}") - print(f" p-value = {_fmt_stat(tt_p)}") - print(f" n = {tt.get('n', 0)}") - print() - - lo = boot.get("lower", 0.0) - hi = boot.get("upper", 0.0) - print(f" Block-bootstrap 95% CI on IC difference:") - print(f" [{_fmt_stat(lo)}, {_fmt_stat(hi)}] " - f"{'(excludes zero **)' if boot.get('excludes_zero') else ''}") - print() - - wil_p = wil.get("p_value", float("nan")) - try: - sig_wil = " *" if float(wil_p) < 0.05 else "" - except (TypeError, ValueError): - sig_wil = "" - print(f" Wilcoxon signed-rank:") - print(f" stat = {_fmt_stat(wil.get('statistic', 0.0), '.1f')}{sig_wil}") - print(f" p-value = {_fmt_stat(wil_p)}") - - -def _generate_markdown_report(bench_result, ablation_result, output_dir: Path) -> str: - """Build and write a comprehensive narrative Markdown report.""" - md = ["# HelixFactor Phase 2 Benchmark Report\n"] - md.append(f"**Generated:** {__import__('datetime').datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") - - md.append("\n## Table 1: Factor Library Metrics\n") - md.append(bench_result.factor_library_metrics.to_markdown(index=False, floatfmt=".4f")) - - md.append("\n\n## Table 2: Factor Combination Metrics\n") - md.append(bench_result.combination_metrics.to_markdown(index=False, floatfmt=".4f")) - - md.append("\n\n## Table 3: Factor Selection Metrics\n") - md.append(bench_result.selection_metrics.to_markdown(index=False, floatfmt=".4f")) - - md.append("\n\n## Table 4: Speed Benchmarks\n") - md.append(bench_result.speed_metrics.to_markdown(index=False, floatfmt=".3f")) - - if not getattr(bench_result, "turnover_metrics", pd.DataFrame()).empty: - md.append("\n\n## Table 5: Turnover Metrics\n") - md.append(bench_result.turnover_metrics.to_markdown(index=False, floatfmt=".4f")) - - if not getattr(bench_result, "cost_pressure_metrics", pd.DataFrame()).empty: - md.append("\n\n## Table 6: Cost Pressure Metrics\n") - md.append( - bench_result.cost_pressure_metrics.to_markdown(index=False, floatfmt=".4f") - ) - - runtime_topk = _runtime_topk_markdown(getattr(bench_result, "runtime_artifacts", {})) - if runtime_topk: - md.append("\n\n## Runtime Top-K\n") - md.append(runtime_topk) - - # Statistical tests - stat = bench_result.statistical_tests - if stat: - md.append("\n\n## Statistical Tests (HelixFactor vs FactorMiner)\n") - dm = stat.get("diebold_mariano", {}) - boot = stat.get("bootstrap_ci_95", {}) - tt = stat.get("paired_t_test", {}) - md.append(f"| Test | Statistic | p-value | Significant |\n|---|---|---|---|\n") - md.append(f"| Diebold-Mariano | {dm.get('dm_stat', 0):.4f} | {dm.get('p_value', 1):.4f} | {dm.get('significant', False)} |\n") - md.append(f"| Paired t-test | {tt.get('t_stat', 0):.4f} | {tt.get('p_value', 1):.4f} | {tt.get('p_value', 1) < 0.05} |\n") - md.append(f"| Bootstrap CI (95%) | [{boot.get('lower', 0):.4f}, {boot.get('upper', 0):.4f}] | — | {boot.get('excludes_zero', False)} |\n") - - if ablation_result is not None and ablation_result.contributions is not None: - md.append("\n\n## Ablation Study: Component Contributions\n") - md.append(ablation_result.contributions.to_markdown(index=False, floatfmt=".4f")) - - content = "\n".join(md) - path = output_dir / "benchmark_report_full.md" - with open(path, "w") as f: - f.write(content) - return str(path) - - -def _write_markdown_table(bench_result, output_dir: Path) -> str: - """Write the concise GitHub-ready markdown table artifact.""" - content = bench_result.to_markdown_table() - path = output_dir / "benchmark_report.md" - with open(path, "w") as f: - f.write(content) - # Keep the historical filename as a compatibility alias. - with open(output_dir / "readme_table.md", "w") as f: - f.write(content) - return str(path) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def main() -> None: - args = _parse_args() - - logging.basicConfig( - level=getattr(logging, args.log_level.upper(), logging.WARNING), - format="%(levelname)s %(name)s: %(message)s", - ) - - output_dir = Path(args.output) - output_dir.mkdir(parents=True, exist_ok=True) - - total_t0 = time.perf_counter() - - # ================================================================ - # STEP 1: Data - # ================================================================ - _section("STEP 1: Prepare Data") - - # Load benchmark modules directly to avoid triggering the package __init__ - _hb = _load_module_direct( - "src.factorminer.benchmark.helix_benchmark", - _REPO_ROOT / "src" / "factorminer" / "benchmark" / "helix_benchmark.py", - ) - _json_safe = _hb._json_safe - HelixBenchmark = _hb.HelixBenchmark - from src.factorminer.utils.config import load_config - - cfg = load_config() - - if args.mock or args.data is None: - print(f" Using mock data: {args.n_assets} assets x {args.n_periods} periods") - t0 = time.perf_counter() - from src.factorminer.data.mock_data import MockConfig, generate_mock_data - - raw_df = generate_mock_data( - MockConfig( - num_assets=args.n_assets, - num_periods=args.n_periods, - frequency="10min", - universe=cfg.data.universe, - plant_alpha=True, - seed=args.seed, - ) - ) - print(f" Generated in {time.perf_counter()-t0:.1f}s") - else: - print(f" Loading real data from: {args.data}") - t0 = time.perf_counter() - from src.factorminer.data.loader import load_market_data - - raw_df = load_market_data(args.data, universe=cfg.data.universe) - print(f" Loaded in {time.perf_counter()-t0:.1f}s") - - train_period, test_period = _derive_split_periods(raw_df) - cfg_runtime = copy.deepcopy(cfg) - cfg_runtime.data.train_period = train_period - cfg_runtime.data.test_period = test_period - cfg_runtime.mining.target_library_size = args.n_factors - cfg_runtime.mining.max_iterations = max(20, args.n_factors * 5) - cfg_runtime.benchmark.seed = args.seed - cfg_runtime.evaluation.backend = "numpy" - cfg_runtime.evaluation.num_workers = min(max(int(cfg_runtime.evaluation.num_workers), 1), 8) - if args.mock: - cfg_runtime.mining.ic_threshold = 0.0 - cfg_runtime.mining.icir_threshold = -1.0 - cfg_runtime.mining.correlation_threshold = 1.1 - - print(f" Shape: M={raw_df['asset_id'].nunique()}, T={raw_df.groupby('asset_id').size().min()}") - print(f" Train: [{train_period[0]}, {train_period[1]}] Test: [{test_period[0]}, {test_period[1]}]") - - # ================================================================ - # STEP 2: Main Comparison Benchmark - # ================================================================ - _section("STEP 2: Main Method Comparison") - - # HelixBenchmark already loaded via _load_module_direct above - - methods = args.methods or [ - "random_exploration", - "alpha101_classic", - "alpha101_adapted", - "ralph_loop", - "helix_phase2", - ] - print(f" Methods: {', '.join(methods)}") - print(f" Target library size: {args.n_factors}") - print() - - bench = HelixBenchmark(seed=args.seed) - t0 = time.perf_counter() - bench_result, runtime_artifacts = bench.run_runtime_comparison( - cfg_runtime, - output_dir, - raw_df=raw_df, - mock=args.mock, - baseline_methods=methods, - n_target_factors=args.n_factors, - n_runs=1, - ) - elapsed = time.perf_counter() - t0 - print(f" Completed in {elapsed:.1f}s") - - # Print method-by-method summary - _subsection("Factor Library Metrics") - print(bench_result.factor_library_metrics.to_string(index=False, float_format="{:.4f}".format)) - - _subsection("Factor Combination Metrics") - print(bench_result.combination_metrics.to_string(index=False, float_format="{:.4f}".format)) - - _subsection("Factor Selection Metrics") - print(bench_result.selection_metrics.to_string(index=False, float_format="{:.4f}".format)) - - # ================================================================ - # STEP 3: HelixFactor vs FactorMiner Improvement Table - # ================================================================ - _section("STEP 3: HelixFactor vs FactorMiner — Improvement Summary") - _print_improvement_table(bench_result) - - # ================================================================ - # STEP 4: Statistical Tests - # ================================================================ - _section("STEP 4: Statistical Significance Tests") - if bench_result.statistical_tests: - _print_stat_tests(bench_result.statistical_tests) - else: - print(" (No statistical tests available — need both helix_phase2 and ralph_loop methods)") - - # ================================================================ - # STEP 5: Speed Benchmark - # ================================================================ - _section("STEP 5: Computational Speed Benchmark") - print(bench_result.speed_metrics.to_string(index=False, float_format="{:.3f}".format)) - - # ================================================================ - # STEP 6: Ablation Study - # ================================================================ - ablation_result = None - if not args.skip_ablation: - _section("STEP 6: Ablation Study") - - if args.full_ablation: - configs_to_run = [ - "full", - "no_debate", - "no_causal", - "no_canonicalize", - "no_regime", - "no_capacity", - "no_significance", - "no_memory", - ] - else: - configs_to_run = [ - "full", - "no_debate", - "no_regime", - "no_capacity", - "no_significance", - "no_memory", - ] - - print(f" Configurations: {', '.join(configs_to_run)}") - t0 = time.perf_counter() - ablation_result = bench.run_runtime_ablation_study( - cfg_runtime, - output_dir, - raw_df=raw_df, - mock=args.mock, - configs_to_run=configs_to_run, - n_target_factors=args.n_factors, - n_runs=1, - ) - elapsed = time.perf_counter() - t0 - print(f" Completed in {elapsed:.1f}s") - - # Attach ablation result to bench_result - bench_result.ablation_result = ablation_result - else: - _section("STEP 6: Ablation Study") - print(" (Skipped via --skip-ablation)") - - # ================================================================ - # STEP 7: Save All Outputs - # ================================================================ - _section("STEP 7: Save Outputs") - - # CSV tables - bench_result.factor_library_metrics.to_csv(output_dir / "library_metrics.csv", index=False) - bench_result.combination_metrics.to_csv(output_dir / "combination_metrics.csv", index=False) - bench_result.selection_metrics.to_csv(output_dir / "selection_metrics.csv", index=False) - bench_result.speed_metrics.to_csv(output_dir / "speed_metrics.csv", index=False) - if not bench_result.turnover_metrics.empty: - bench_result.turnover_metrics.to_csv(output_dir / "turnover_metrics.csv", index=False) - if not bench_result.cost_pressure_metrics.empty: - bench_result.cost_pressure_metrics.to_csv(output_dir / "cost_pressure_metrics.csv", index=False) - runtime_topk = _runtime_topk_frame(runtime_artifacts) - if not runtime_topk.empty: - runtime_topk.to_csv(output_dir / "runtime_topk.csv", index=False) - - # Statistical tests JSON - with open(output_dir / "statistical_tests.json", "w") as f: - json.dump(_json_safe(bench_result.statistical_tests), f, indent=2, allow_nan=False) - - # LaTeX table (Table 1 style) - with open(output_dir / "latex_table.tex", "w") as f: - f.write(bench_result.to_latex_table()) - - # Markdown table - table_path = _write_markdown_table(bench_result, output_dir) - - # HTML report - bench_result.generate_full_report(str(output_dir / "benchmark_report.html")) - - # Comprehensive Markdown report - md_path = _generate_markdown_report(bench_result, ablation_result, output_dir) - - # Ablation outputs - if ablation_result is not None: - if ablation_result.contributions is not None: - ablation_result.contributions.to_csv( - output_dir / "ablation_contributions.csv", index=False - ) - with open(output_dir / "ablation_table.tex", "w") as f: - f.write(ablation_result.contributions.to_latex(index=False) if ablation_result.contributions is not None else "% No ablation data available") - - # Bar chart comparison - try: - bench_result.plot_comparison(str(output_dir / "comparison_plot.png")) - print(f" comparison_plot.png saved") - except Exception as exc: - print(f" (Plot skipped: {exc})") - - phase2_artifact_paths = { - "html_report": str((output_dir / "benchmark_report.html").resolve()), - "markdown_table": str((output_dir / "benchmark_report.md").resolve()), - "narrative_markdown": str((output_dir / "benchmark_report_full.md").resolve()), - "latex_table": str((output_dir / "latex_table.tex").resolve()), - "manifest": str((output_dir / "phase2_manifest.json").resolve()), - "statistical_tests": str((output_dir / "statistical_tests.json").resolve()), - "library_metrics": str((output_dir / "library_metrics.csv").resolve()), - "combination_metrics": str((output_dir / "combination_metrics.csv").resolve()), - "selection_metrics": str((output_dir / "selection_metrics.csv").resolve()), - "speed_metrics": str((output_dir / "speed_metrics.csv").resolve()), - } - if (output_dir / "turnover_metrics.csv").exists(): - phase2_artifact_paths["turnover_metrics"] = str( - (output_dir / "turnover_metrics.csv").resolve() - ) - if (output_dir / "cost_pressure_metrics.csv").exists(): - phase2_artifact_paths["cost_pressure_metrics"] = str( - (output_dir / "cost_pressure_metrics.csv").resolve() - ) - if (output_dir / "runtime_topk.csv").exists(): - phase2_artifact_paths["runtime_topk"] = str( - (output_dir / "runtime_topk.csv").resolve() - ) - if (output_dir / "comparison_plot.png").exists(): - phase2_artifact_paths["comparison_plot"] = str( - (output_dir / "comparison_plot.png").resolve() - ) - if ablation_result is not None and ablation_result.contributions is not None: - phase2_artifact_paths["ablation_contributions"] = str( - (output_dir / "ablation_contributions.csv").resolve() - ) - if ablation_result is not None: - phase2_artifact_paths["ablation_table"] = str( - (output_dir / "ablation_table.tex").resolve() - ) - - phase2_manifest = _build_phase2_manifest( - output_dir=output_dir.resolve(), - methods=methods, - seed=args.seed, - n_factors=args.n_factors, - mock=args.mock, - data_path=args.data, - full_ablation=args.full_ablation, - skip_ablation=args.skip_ablation, - artifact_paths=phase2_artifact_paths, - statistical_tests=bench_result.statistical_tests, - ablation_configs=getattr(ablation_result, "configs", None), - runtime_manifest_root=output_dir, - ) - with open(output_dir / "phase2_manifest.json", "w") as f: - json.dump(_json_safe(phase2_manifest), f, indent=2, allow_nan=False) - - print(f"\n Output files saved to: {output_dir.resolve()}") - for fpath in sorted(output_dir.glob("*")): - size = fpath.stat().st_size - print(f" {fpath.name:<40} {size:>8,} bytes") - - # ================================================================ - # SUMMARY - # ================================================================ - _section("BENCHMARK COMPLETE") - - total_elapsed = time.perf_counter() - total_t0 - print(f" Total runtime: {total_elapsed:.1f}s") - print() - print(f" Methods benchmarked: {len(methods)}") - print(f" Factors per method: {args.n_factors}") - print(f" Runtime manifests discovered: {len(runtime_artifacts.get('runtime_payloads', {}))}") - - if ablation_result is not None: - print(f" Ablation configs: {len(ablation_result.configs)}") - - if bench_result.statistical_tests.get("helix_outperforms"): - print() - print(" *** HelixFactor OUTPERFORMS FactorMiner ***") - dm = bench_result.statistical_tests.get("diebold_mariano", {}) - if dm.get("significant"): - print(f" *** DM test significant: p={dm.get('p_value', 1):.4f} ***") - print() - print(f" Full report: {output_dir.resolve() / 'benchmark_report.html'}") - print(f" Markdown table: {table_path}") - print(f" Narrative markdown: {md_path}") - print() - - -if __name__ == "__main__": - main() diff --git a/src/factorminer/tests/test_auto_inventor.py b/src/factorminer/tests/test_auto_inventor.py index a609cb2..c51ed99 100644 --- a/src/factorminer/tests/test_auto_inventor.py +++ b/src/factorminer/tests/test_auto_inventor.py @@ -117,8 +117,8 @@ def test_proposed_operator_dataclass(): # ----------------------------------------------------------------------- def _mock_provider(): - from src.factorminer.agent.llm_interface import MockProvider - return MockProvider() + + return None def _make_inventor(): diff --git a/src/factorminer/tests/test_debate.py b/src/factorminer/tests/test_debate.py index d287bab..332ab38 100644 --- a/src/factorminer/tests/test_debate.py +++ b/src/factorminer/tests/test_debate.py @@ -6,7 +6,7 @@ import pytest from src.factorminer.agent.critic import CriticAgent from src.factorminer.agent.debate import DebateConfig, DebateGenerator -from src.factorminer.agent.llm_interface import MockProvider + from src.factorminer.agent.output_parser import CandidateFactor from src.factorminer.agent.prompt_builder import PromptBuilder from src.factorminer.agent.specialists import ( @@ -138,7 +138,7 @@ def test_specialist_prompt_builder_renders_helix_retrieval_fields( def test_critic_agent_with_mock(): """CriticAgent should produce scores when given proposals.""" - provider = MockProvider() + provider = None critic = CriticAgent(llm_provider=provider) candidates = [ @@ -162,7 +162,7 @@ def test_critic_agent_with_mock(): # ----------------------------------------------------------------------- def test_debate_generator_returns_candidates(): - provider = MockProvider() + provider = None gen = DebateGenerator( llm_provider=provider, debate_config=DebateConfig( @@ -182,7 +182,7 @@ def test_debate_generator_returns_candidates(): # ----------------------------------------------------------------------- def test_debate_generator_with_critic(): - provider = MockProvider() + provider = None gen = DebateGenerator( llm_provider=provider, debate_config=DebateConfig( @@ -197,7 +197,7 @@ def test_debate_generator_with_critic(): def test_debate_generator_accepts_dict_recent_admissions(): - provider = MockProvider() + provider = None gen = DebateGenerator( llm_provider=provider, debate_config=DebateConfig( diff --git a/src/factorminer/tests/test_helix_loop.py b/src/factorminer/tests/test_helix_loop.py index ea1d45e..5310738 100644 --- a/src/factorminer/tests/test_helix_loop.py +++ b/src/factorminer/tests/test_helix_loop.py @@ -11,7 +11,7 @@ try: except ImportError: HAS_HELIX = False -from src.factorminer.agent.llm_interface import MockProvider + from src.factorminer.core.factor_library import Factor, FactorLibrary from src.factorminer.core.config import MiningConfig from src.factorminer.core.ralph_loop import EvaluationResult @@ -44,7 +44,7 @@ def test_helix_loop_instantiates_with_defaults(small_tensor): """HelixLoop with all features off should be instantiable.""" data, returns = small_tensor config = MiningConfig(target_library_size=5, max_iterations=1) - provider = MockProvider() + provider = None loop = HelixLoop( config=config, @@ -66,7 +66,7 @@ def test_helix_loop_canonicalize_flag(small_tensor): """HelixLoop with canonicalize=True should initialize the canonicalizer.""" data, returns = small_tensor config = MiningConfig(target_library_size=5, max_iterations=1) - provider = MockProvider() + provider = None loop = HelixLoop( config=config, @@ -83,14 +83,14 @@ def test_helix_loop_canonicalize_flag(small_tensor): # ----------------------------------------------------------------------- def test_helix_loop_runs_one_iteration(small_tensor): - """HelixLoop should complete 1 iteration without error using MockProvider.""" + """HelixLoop should complete 1 iteration without error using llm_provider=None.""" data, returns = small_tensor config = MiningConfig( target_library_size=3, max_iterations=1, batch_size=5, ) - provider = MockProvider() + provider = None loop = HelixLoop( config=config, @@ -116,7 +116,7 @@ def test_phase2_revocation_updates_stats_and_library_state(small_tensor): ic_threshold=0.0001, correlation_threshold=0.95, ) - provider = MockProvider() + provider = None loop = HelixLoop( config=config, @@ -151,7 +151,7 @@ def test_revoke_admission_rebuilds_library_indices(small_tensor): """Revoking a factor should rebuild the library correlation bookkeeping.""" data, returns = small_tensor config = MiningConfig(target_library_size=5, max_iterations=1) - provider = MockProvider() + provider = None loop = HelixLoop( config=config, @@ -209,7 +209,7 @@ def test_helix_embedding_screen_filters_library_duplicates(small_tensor): """Embedding-aware synthesis should drop near-duplicates of admitted factors.""" data, returns = small_tensor config = MiningConfig(target_library_size=5, max_iterations=1) - provider = MockProvider() + provider = None library = FactorLibrary(correlation_threshold=0.95, ic_threshold=0.0001) library.admit_factor( diff --git a/src/factorminer/tests/test_provenance.py b/src/factorminer/tests/test_provenance.py index ca563b4..8074f63 100644 --- a/src/factorminer/tests/test_provenance.py +++ b/src/factorminer/tests/test_provenance.py @@ -6,7 +6,7 @@ import json import numpy as np -from src.factorminer.agent.llm_interface import MockProvider + from src.factorminer.core.factor_library import Factor from src.factorminer.core.library_io import load_library from src.factorminer.core.config import MiningConfig @@ -60,7 +60,7 @@ def test_helix_run_writes_manifest_and_factor_provenance(tmp_path, small_data, m batch_size=1, output_dir=str(tmp_path / "helix-output"), ) - provider = MockProvider() + provider = None loop = HelixLoop( config=config, diff --git a/src/factorminer/tests/test_ralph_loop.py b/src/factorminer/tests/test_ralph_loop.py index b63e522..358201f 100644 --- a/src/factorminer/tests/test_ralph_loop.py +++ b/src/factorminer/tests/test_ralph_loop.py @@ -23,7 +23,7 @@ from typing import Any, Dict, Optional import numpy as np import pytest -from src.factorminer.agent.llm_interface import MockProvider + from src.factorminer.core.factor_library import Factor, FactorLibrary from src.factorminer.core.ralph_loop import ( BudgetTracker, @@ -99,7 +99,7 @@ def synthetic_data(rng): @pytest.fixture def mock_provider(): - return MockProvider(cycle=True) + return None @pytest.fixture @@ -251,14 +251,14 @@ class TestFactorGenerator: assert len(result) == 2 def test_mock_provider_deterministic(self): - p1 = MockProvider(cycle=False) - p2 = MockProvider(cycle=False) + p1 = None + p2 = None r1 = p1.generate("sys", "user", 0.8, 4096) r2 = p2.generate("sys", "user", 0.8, 4096) assert r1 == r2 def test_mock_provider_cycling(self): - p = MockProvider(cycle=True) + p = None r1 = p.generate("sys", "user") r2 = p.generate("sys", "user") # Second call should produce different factors (cycled offset) @@ -640,7 +640,7 @@ class TestRalphLoopEndToEnd: data_tensor, returns = synthetic_data # Provider that returns empty response - class EmptyProvider(MockProvider): + class EmptyProvider: def generate(self, *args, **kwargs): return "" @@ -706,7 +706,7 @@ class TestSessionPersistence: config=test_config, data_tensor=data_tensor, returns=returns, - llm_provider=MockProvider(cycle=True), + llm_provider=None, ) loop2.load_session(checkpoint_path) assert loop2.iteration == loop1.iteration @@ -730,7 +730,7 @@ class TestSessionPersistence: config=test_config, data_tensor=data_tensor, returns=returns, - llm_provider=MockProvider(cycle=True), + llm_provider=None, ) loop2.load_session(checkpoint_path) # Memory should have been restored @@ -803,7 +803,7 @@ class TestCheckpointResume: config=test_config, data_tensor=data_tensor, returns=returns, - llm_provider=MockProvider(cycle=True), + llm_provider=None, ) assert loop2.iteration == 0 # Starts fresh @@ -842,7 +842,7 @@ class TestCheckpointResume: config=test_config, data_tensor=data_tensor, returns=returns, - llm_provider=MockProvider(cycle=True), + llm_provider=None, ) checkpoint_dir = os.path.join(tmp_dir, "checkpoint") loop2.load_session(checkpoint_dir) @@ -882,7 +882,7 @@ class TestCheckpointResume: config=test_config, data_tensor=data_tensor, returns=returns, - llm_provider=MockProvider(cycle=True), + llm_provider=None, ) checkpoint_dir = os.path.join(tmp_dir, "checkpoint") loop2.load_session(checkpoint_dir) @@ -965,7 +965,7 @@ class TestCheckpointResume: config=test_config, data_tensor=data_tensor, returns=returns, - llm_provider=MockProvider(cycle=True), + llm_provider=None, ) assert loop2.iteration == loop1.iteration @@ -993,7 +993,7 @@ class TestCheckpointResume: config=test_config, data_tensor=data_tensor, returns=returns, - llm_provider=MockProvider(cycle=True), + llm_provider=None, ) checkpoint_dir = os.path.join(tmp_dir, "checkpoint") loop2.load_session(checkpoint_dir) diff --git a/tests/test_factorminer_e2e.py b/tests/test_factorminer_e2e.py new file mode 100644 index 0000000..ffdce8c --- /dev/null +++ b/tests/test_factorminer_e2e.py @@ -0,0 +1,117 @@ +"""端到端集成测试:验证 110 个 Paper Factors 能被本地引擎计算。 + +测试目标: +1. 加载 paper factors 库 +2. 使用 LocalFactorEvaluator 计算每个因子 +3. 验证输出形状为 (M, T) 且不含全 NaN +4. 统计成功率和跳过数 + +运行命令:uv run pytest tests/test_factorminer_e2e.py -v +""" + +import pytest + + +def test_paper_factors_e2e(): + """端到端测试:验证 110 个 Paper Factors 能被本地引擎计算。 + + 注意:此测试需要数据库中有 pro_bar 数据。 + 如果数据不足,会在计算时失败。 + """ + from src.factorminer.core.library_io import import_from_paper + from src.factorminer.evaluation.local_engine import LocalFactorEvaluator + + # 1. 加载 paper factors 库 + library = import_from_paper() + factors = library.list_factors() + total = len(factors) + + print(f"\n[e2e] 总计加载 {total} 个因子") + + # 2. 过滤 unsupported 因子 + supported_factors = [f for f in factors if not f.metadata.get("unsupported", False)] + unsupported_count = total - len(supported_factors) + + print(f"[e2e] 可计算因子 {len(supported_factors)} 个") + print(f"[e2e] 跳过(未实现算子){unsupported_count} 个") + + if not supported_factors: + print("[e2e] 没有可计算的因子,测试跳过") + pytest.skip("No supported factors to test") + + # 3. 实例化 LocalFactorEvaluator + evaluator = LocalFactorEvaluator( + start_date="20200101", + end_date="20201231", + ) + + # 4. 批量计算每个因子 + success_count = 0 + fail_count = 0 + skip_count = 0 + + specs = [(f.name, f.formula) for f in supported_factors] + + print(f"[e2e] 开始批量计算 {len(specs)} 个因子...") + + try: + results = evaluator.evaluate(specs) + except Exception as e: + print(f"[e2e] 批量计算失败: {e}") + pytest.fail(f"Batch evaluation failed: {e}") + + # 5. 验证结果 + for name, result in results.items(): + if result is None: + fail_count += 1 + continue + + # 检查形状 + if result.ndim != 2: + print(f"[e2e] 因子 {name} 形状错误: {result.ndim}D(期望 2D)") + fail_count += 1 + continue + + # 检查是否全 NaN + if np.isnan(result).all(): + print(f"[e2e] 因子 {name} 输出全为 NaN") + fail_count += 1 + continue + + success_count += 1 + + print( + f"[e2e] 成功 {success_count}/{total}," + f"跳过 {skip_count} 个,失败 {fail_count} 个" + ) + + # 断言:至少 50% 的因子能成功计算(宽松标准) + success_rate = success_count / total + assert success_rate >= 0.5, f"成功率 {success_rate:.1%} 低于 50% 阈值" + + +if __name__ == "__main__": + import numpy as np + from src.factorminer.core.library_io import import_from_paper + from src.factorminer.evaluation.local_engine import LocalFactorEvaluator + + # 手动运行测试 + library = import_from_paper() + factors = library.list_factors() + total = len(factors) + print(f"\n[e2e] 总计加载 {total} 个因子") + + supported_factors = [f for f in factors if not f.metadata.get("unsupported", False)] + unsupported_count = total - len(supported_factors) + print(f"[e2e] 可计算因子 {len(supported_factors)} 个") + print(f"[e2e] 跳过(未实现算子){unsupported_count} 个") + + if supported_factors: + evaluator = LocalFactorEvaluator( + start_date="20200101", + end_date="20201231", + ) + specs = [(f.name, f.formula) for f in supported_factors] + print(f"[e2e] 开始批量计算 {len(specs)} 个因子...") + results = evaluator.evaluate(specs) + print(f"[e2e] 批量计算完成,共 {len(results)} 个结果")