diff --git a/src/quant_engine/alpha_factors.py b/src/quant_engine/alpha_factors.py index 760dda8..e72e9d1 100644 --- a/src/quant_engine/alpha_factors.py +++ b/src/quant_engine/alpha_factors.py @@ -3539,6 +3539,95 @@ def evaluate_phase5_formula(name: str, **inputs: pd.Series) -> pd.Series: return function(*(inputs[field] for field in required_inputs)) +# ── Phase 6 formula contract: frozen alpha151-alpha158 surface ────────────── + +# Phase 6 completes the versioned formula contract without mutating any +# earlier catalogue, digest, dispatch surface, or formula implementation. +ALPHA158_PHASE6_FORMULA_CONTRACT_VERSION = "1.0.0" +ALPHA158_PHASE6_FORMULA_CATALOG_SHA256 = ( + "70ffae16ca6cbb59a1e8644f5cdeec1d291fa75ff8b5647fb534af0083408219" +) + +_PHASE6_FORMULA_FUNCTIONS: dict[str, Callable[..., pd.Series]] = { + "alpha_151": alpha_151, + "alpha_152": alpha_152, + "alpha_153": alpha_153, + "alpha_154": alpha_154, + "alpha_155": alpha_155, + "alpha_156": alpha_156, + "alpha_157": alpha_157, + "alpha_158": alpha_158, +} + + +def _build_phase6_formula_specs() -> dict[str, dict[str, Any]]: + specs: dict[str, dict[str, Any]] = {} + for alpha_id, function in _PHASE6_FORMULA_FUNCTIONS.items(): + meta = ALPHA158_REGISTRY[alpha_id] + call_inputs = _phase3_call_inputs(function) + formula_inputs = _phase3_string_list(meta, "inputs", alpha_id) + input_category = _PHASE4_INPUT_CATEGORIES.get(len(call_inputs)) + if input_category is None: + raise RuntimeError(f"unsupported formula input count for {alpha_id}") + specs[alpha_id] = { + "name": alpha_id, + "contract_version": ALPHA158_PHASE6_FORMULA_CONTRACT_VERSION, + "formula": meta["formula"], + "category": meta["category"], + "complexity": meta["complexity"], + "parameters": _phase3_string_list(meta, "params", alpha_id), + "description": meta["description"], + "references": _phase3_string_list(meta, "references", alpha_id), + "call_inputs": list(call_inputs), + "formula_inputs": formula_inputs, + "input_category": input_category, + } + return specs + + +ALPHA158_PHASE6_FORMULA_SPECS: Mapping[str, Mapping[str, Any]] = ( + _freeze_phase3_formula_specs(_build_phase6_formula_specs()) +) + + +def list_phase6_formulas() -> tuple[str, ...]: + """Return the frozen alpha151-alpha158 formula IDs in stable order.""" + return tuple(ALPHA158_PHASE6_FORMULA_SPECS) + + +def evaluate_phase6_formula(name: str, **inputs: pd.Series) -> pd.Series: + """Evaluate a Phase 6 formula with an exact, alignment-safe input contract.""" + if name not in ALPHA158_PHASE6_FORMULA_SPECS: + raise KeyError(f"formula {name!r} not registered") + + spec = ALPHA158_PHASE6_FORMULA_SPECS[name] + required_inputs = cast(tuple[str, ...], spec["call_inputs"]) + missing_inputs = [field for field in required_inputs if field not in inputs] + unexpected_inputs = sorted(field for field in inputs if field not in required_inputs) + if missing_inputs or unexpected_inputs: + details: list[str] = [] + if missing_inputs: + details.append(f"missing inputs {missing_inputs}") + if unexpected_inputs: + details.append(f"unexpected inputs {unexpected_inputs}") + raise ValueError(f"invalid inputs for {name}: {'; '.join(details)}") + + for field in required_inputs: + if not isinstance(inputs[field], pd.Series): + raise TypeError(f"{field} must be a pandas Series") + + primary_field = required_inputs[0] + primary = inputs[primary_field] + for field in required_inputs[1:]: + if len(inputs[field]) != len(primary): + raise ValueError(f"{field} length must match {primary_field}") + if not primary.index.equals(inputs[field].index): + raise ValueError(f"{field} index must align with {primary_field}") + + function = _PHASE6_FORMULA_FUNCTIONS[name] + return function(*(inputs[field] for field in required_inputs)) + + __all__ = [ "rank", "delta", @@ -3587,6 +3676,11 @@ __all__ = [ "ALPHA158_PHASE5_FORMULA_SPECS", "list_phase5_formulas", "evaluate_phase5_formula", + "ALPHA158_PHASE6_FORMULA_CONTRACT_VERSION", + "ALPHA158_PHASE6_FORMULA_CATALOG_SHA256", + "ALPHA158_PHASE6_FORMULA_SPECS", + "list_phase6_formulas", + "evaluate_phase6_formula", "alpha_001", "alpha_002", "alpha_003", diff --git a/tests/test_alpha_factors.py b/tests/test_alpha_factors.py index 4b607f3..1786bd6 100644 --- a/tests/test_alpha_factors.py +++ b/tests/test_alpha_factors.py @@ -20,6 +20,9 @@ from quant_engine.alpha_factors import ( ALPHA158_PHASE5_FORMULA_CATALOG_SHA256, ALPHA158_PHASE5_FORMULA_CONTRACT_VERSION, ALPHA158_PHASE5_FORMULA_SPECS, + ALPHA158_PHASE6_FORMULA_CATALOG_SHA256, + ALPHA158_PHASE6_FORMULA_CONTRACT_VERSION, + ALPHA158_PHASE6_FORMULA_SPECS, alpha_001, alpha_002, alpha_003, @@ -183,11 +186,13 @@ from quant_engine.alpha_factors import ( evaluate_phase3_formula, evaluate_phase4_formula, evaluate_phase5_formula, + evaluate_phase6_formula, list_phase1_operators, list_phase2_operators, list_phase3_formulas, list_phase4_formulas, list_phase5_formulas, + list_phase6_formulas, correlation, covariance, decay_linear, @@ -1962,3 +1967,193 @@ def test_phase5_contract_is_publicly_exported(): "list_phase5_formulas", "evaluate_phase5_formula", } <= set(alpha_factors_module.__all__) + + +# ── Alpha158 Phase 6: versioned alpha151-alpha158 formula contract ────────── + + +def test_phase6_formula_catalog_is_versioned_exact_and_content_addressed(): + import hashlib + import json + from collections import Counter + + expected_ids = tuple(f"alpha_{number:03d}" for number in range(151, 159)) + expected_fields = { + "name", + "contract_version", + "formula", + "category", + "complexity", + "parameters", + "description", + "references", + "call_inputs", + "formula_inputs", + "input_category", + } + + assert ALPHA158_PHASE6_FORMULA_CONTRACT_VERSION == "1.0.0" + assert list_phase6_formulas() == expected_ids + assert tuple(ALPHA158_PHASE6_FORMULA_SPECS) == expected_ids + assert Counter( + spec["input_category"] for spec in ALPHA158_PHASE6_FORMULA_SPECS.values() + ) == {"pair": 6, "triple": 2} + + for alpha_id, spec in ALPHA158_PHASE6_FORMULA_SPECS.items(): + assert set(spec) == expected_fields + assert spec["name"] == alpha_id + assert spec["contract_version"] == ALPHA158_PHASE6_FORMULA_CONTRACT_VERSION + assert spec["formula"] == ALPHA158_REGISTRY[alpha_id]["formula"] + + serializable_specs = { + alpha_id: { + field: list(value) if isinstance(value, tuple) else value + for field, value in spec.items() + } + for alpha_id, spec in ALPHA158_PHASE6_FORMULA_SPECS.items() + } + encoded = json.dumps( + serializable_specs, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + assert hashlib.sha256(encoded).hexdigest() == ALPHA158_PHASE6_FORMULA_CATALOG_SHA256 + assert ALPHA158_PHASE6_FORMULA_CATALOG_SHA256 == ( + "70ffae16ca6cbb59a1e8644f5cdeec1d291fa75ff8b5647fb534af0083408219" + ) + + +def test_phase6_formula_catalog_is_recursively_immutable(): + import operator + + with pytest.raises(TypeError): + operator.setitem(ALPHA158_PHASE6_FORMULA_SPECS, "alpha_151", {}) + with pytest.raises(TypeError): + operator.setitem( + ALPHA158_PHASE6_FORMULA_SPECS["alpha_151"], + "formula", + "changed", + ) + with pytest.raises(TypeError): + operator.setitem( + ALPHA158_PHASE6_FORMULA_SPECS["alpha_151"]["call_inputs"], + 0, + "volume", + ) + + +def test_phase6_catalog_freezes_callable_and_formula_inputs(): + import inspect + + for alpha_id, spec in ALPHA158_PHASE6_FORMULA_SPECS.items(): + function = getattr(alpha_factors_module, alpha_id) + signature_inputs = tuple( + "open" if name == "open_" else name + for name in inspect.signature(function).parameters + ) + assert spec["call_inputs"] == signature_inputs + assert spec["formula_inputs"] == tuple(ALPHA158_REGISTRY[alpha_id]["inputs"]) + + assert ALPHA158_PHASE6_FORMULA_SPECS["alpha_158"]["call_inputs"] == ( + "high", + "low", + "volume", + ) + + +def test_phase6_dispatch_matches_all_existing_alpha151_alpha158_functions(): + inputs = _phase3_market_inputs() + + for alpha_id, spec in ALPHA158_PHASE6_FORMULA_SPECS.items(): + call_inputs = spec["call_inputs"] + function = getattr(alpha_factors_module, alpha_id) + expected = function(*(inputs[name] for name in call_inputs)) + actual = evaluate_phase6_formula( + alpha_id, + **{name: inputs[name] for name in reversed(call_inputs)}, + ) + pd.testing.assert_series_equal(actual, expected) + + +def test_phase6_dispatch_rejects_unknown_missing_extra_and_non_series_inputs(): + inputs = _phase3_market_inputs() + + with pytest.raises(KeyError, match="not registered"): + evaluate_phase6_formula("alpha_150", close=inputs["close"]) + with pytest.raises(KeyError, match="not registered"): + evaluate_phase6_formula("alpha_159", close=inputs["close"]) + with pytest.raises(ValueError, match=r"missing inputs.*volume"): + evaluate_phase6_formula("alpha_151", close=inputs["close"]) + with pytest.raises(ValueError, match=r"unexpected inputs.*vwap"): + evaluate_phase6_formula( + "alpha_151", + close=inputs["close"], + volume=inputs["volume"], + vwap=inputs["vwap"], + ) + with pytest.raises(TypeError, match="volume must be a pandas Series"): + evaluate_phase6_formula( # type: ignore[arg-type] + "alpha_151", + close=inputs["close"], + volume=[1.0, 2.0], + ) + + +def test_phase6_dispatch_rejects_length_and_index_alignment_errors(): + inputs = _phase3_market_inputs() + shorter_volume = inputs["volume"].iloc[:-1] + misaligned_low = inputs["low"].rename(index={79: 80}) + + with pytest.raises(ValueError, match="volume length must match close"): + evaluate_phase6_formula( + "alpha_151", + close=inputs["close"], + volume=shorter_volume, + ) + with pytest.raises(ValueError, match="low index must align with high"): + evaluate_phase6_formula( + "alpha_158", + high=inputs["high"], + low=misaligned_low, + volume=inputs["volume"], + ) + + +def test_phase6_preserves_complete_alpha001_alpha158_formula_body_fingerprint(): + import ast + import hashlib + import inspect + import json + import textwrap + + fingerprints = {} + for number in range(1, 159): + alpha_id = f"alpha_{number:03d}" + source = textwrap.dedent(inspect.getsource(getattr(alpha_factors_module, alpha_id))) + node = ast.parse(source).body[0] + assert isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + body = ast.dump( + ast.Module(body=node.body, type_ignores=[]), + include_attributes=False, + ) + fingerprints[alpha_id] = hashlib.sha256(body.encode()).hexdigest() + + encoded = json.dumps( + fingerprints, + sort_keys=True, + separators=(",", ":"), + ).encode() + assert hashlib.sha256(encoded).hexdigest() == ( + "f3ae807983cf8ca083d0b924c3807ffd84a62a5bd354f9efb1a1a16ca40c7da6" + ) + + +def test_phase6_contract_is_publicly_exported(): + assert { + "ALPHA158_PHASE6_FORMULA_CONTRACT_VERSION", + "ALPHA158_PHASE6_FORMULA_CATALOG_SHA256", + "ALPHA158_PHASE6_FORMULA_SPECS", + "list_phase6_formulas", + "evaluate_phase6_formula", + } <= set(alpha_factors_module.__all__)