"""Build qlib factor expressions from operator trees (YAML/JSON AST).""" from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import Any import yaml from config.settings import PROJECT_ROOT from factor_engine.formula_registry import add_factor_to_registry, load_registry, save_registry DEFAULT_BUILTINS_PATH = PROJECT_ROOT / "config" / "factor_builtins.yaml" @dataclass class BuiltFactorSpec: name: str tree: dict[str, Any] description: str = "" tags: list[str] | None = None enabled: bool = True def load_builtins(path: str | Path | None = None) -> dict[str, Any]: path = Path(path) if path else DEFAULT_BUILTINS_PATH if not path.is_absolute(): path = PROJECT_ROOT / path if not path.exists(): return {"built_factors": {}, "operator_catalog": {}} with open(path, encoding="utf-8") as f: return yaml.safe_load(f) or {} def list_built_factors(path: str | Path | None = None, enabled_only: bool = False) -> list[BuiltFactorSpec]: raw = load_builtins(path) out = [] for name, info in raw.get("built_factors", {}).items(): spec = BuiltFactorSpec( name=name, tree=info["tree"], description=info.get("description", ""), tags=info.get("tags") or [], enabled=bool(info.get("enabled", True)), ) if enabled_only and not spec.enabled: continue out.append(spec) return out def get_built_factor(name: str, path: str | Path | None = None) -> BuiltFactorSpec: for spec in list_built_factors(path): if spec.name == name: return spec raise KeyError(f"Built factor not found: {name}") def _render_node(node: Any) -> str: if node is None: raise ValueError("Empty operator node") if isinstance(node, (int, float)): return str(node) if isinstance(node, str): return node if isinstance(node, dict): if "field" in node: return str(node["field"]) if "const" in node: return str(int(node["const"])) op = str(node.get("op", "")).lower() args = node.get("args", []) if op == "field": return str(node.get("name") or node.get("value")) if op in ("add", "sub", "mul", "div"): if len(args) != 2: raise ValueError(f"{op} requires 2 args") left, right = (_render_node(a) for a in args) sym = {"add": "+", "sub": "-", "mul": "*", "div": "/"}[op] return f"({left}){sym}({right})" if op in ("max", "min"): if len(args) != 2: raise ValueError(f"{op} requires 2 args") fname = "Greater" if op == "max" else "Less" return f"{fname}({_render_node(args[0])}, {_render_node(args[1])})" if op == "abs": return f"Abs({_render_node(args[0])})" if op == "log": return f"Log({_render_node(args[0])})" if op == "rank": window = int(_render_node(args[1])) if len(args) > 1 else 20 return f"Rank({_render_node(args[0])}, {window})" if op == "neg": return f"(-({_render_node(args[0])}))" if op == "mean": return f"Mean({_render_node(args[0])}, {int(_render_node(args[1]))})" if op == "std": return f"Std({_render_node(args[0])}, {int(_render_node(args[1]))})" if op == "sum": return f"Sum({_render_node(args[0])}, {int(_render_node(args[1]))})" if op == "ref": d = int(_render_node(args[1])) sign = -abs(d) return f"Ref({_render_node(args[0])}, {sign})" if op == "delta": d = int(_render_node(args[1])) x = _render_node(args[0]) return f"({x}-Ref({x}, -{abs(d)}))" raise ValueError(f"Unsupported operator: {op}") raise TypeError(f"Unsupported node type: {type(node)}") def tree_to_qlib_expression(tree: dict[str, Any]) -> str: return _render_node(tree) def build_expression(name: str, path: str | Path | None = None) -> str: spec = get_built_factor(name, path) return tree_to_qlib_expression(spec.tree) def register_built_factor( name: str, builtins_path: str | Path | None = None, registry_path: str | Path | None = None, overwrite: bool = True, ) -> str: """Compile operator tree → qlib expression and write into factor_registry.yaml.""" spec = get_built_factor(name, builtins_path) expr = tree_to_qlib_expression(spec.tree) reg_path = Path(registry_path) if registry_path else PROJECT_ROOT / "config" / "factor_registry.yaml" if not reg_path.is_absolute(): reg_path = PROJECT_ROOT / reg_path data = load_registry(reg_path) if reg_path.exists() else {"defaults": {}, "factors": {}} if not overwrite and name in data.get("factors", {}): raise FileExistsError(f"Factor already exists in registry: {name}") data.setdefault("factors", {})[name] = { "expression": expr, "description": spec.description or f"Built from operator tree ({name})", "tags": (spec.tags or []) + ["operator_built"], "enabled": spec.enabled, "source": "operator_builder", "operator_tree": spec.tree, } save_registry(data, reg_path) return expr def register_all_built_factors( builtins_path: str | Path | None = None, registry_path: str | Path | None = None, enabled_only: bool = True, ) -> dict[str, str]: results = {} for spec in list_built_factors(builtins_path, enabled_only=enabled_only): expr = register_built_factor(spec.name, builtins_path, registry_path, overwrite=True) results[spec.name] = expr return results def get_operator_catalog(path: str | Path | None = None) -> dict[str, Any]: return load_builtins(path).get("operator_catalog", {})