diff --git a/src/quant_engine/alpha_factors.py b/src/quant_engine/alpha_factors.py index f71fcc8..4cfe7db 100644 --- a/src/quant_engine/alpha_factors.py +++ b/src/quant_engine/alpha_factors.py @@ -3271,6 +3271,143 @@ def evaluate_phase3_formula(name: str, **inputs: pd.Series) -> pd.Series: return function(*(inputs[field] for field in required_inputs)) +# ── Phase 4 formula contract: frozen alpha051-alpha100 surface ────────────── + +# Phase 4 extends the versioned formula contract without mutating the Phase 3 +# catalogue, digest, dispatch surface, or the existing formula functions. +ALPHA158_PHASE4_FORMULA_CONTRACT_VERSION = "1.0.0" +ALPHA158_PHASE4_FORMULA_CATALOG_SHA256 = ( + "858daf5e2abab5063fc28fcf7c79936096e2c76dde582fc7bab78b3458f17054" +) + +_PHASE4_FORMULA_FUNCTIONS: dict[str, Callable[..., pd.Series]] = { + "alpha_051": alpha_051, + "alpha_052": alpha_052, + "alpha_053": alpha_053, + "alpha_054": alpha_054, + "alpha_055": alpha_055, + "alpha_056": alpha_056, + "alpha_057": alpha_057, + "alpha_058": alpha_058, + "alpha_059": alpha_059, + "alpha_060": alpha_060, + "alpha_061": alpha_061, + "alpha_062": alpha_062, + "alpha_063": alpha_063, + "alpha_064": alpha_064, + "alpha_065": alpha_065, + "alpha_066": alpha_066, + "alpha_067": alpha_067, + "alpha_068": alpha_068, + "alpha_069": alpha_069, + "alpha_070": alpha_070, + "alpha_071": alpha_071, + "alpha_072": alpha_072, + "alpha_073": alpha_073, + "alpha_074": alpha_074, + "alpha_075": alpha_075, + "alpha_076": alpha_076, + "alpha_077": alpha_077, + "alpha_078": alpha_078, + "alpha_079": alpha_079, + "alpha_080": alpha_080, + "alpha_081": alpha_081, + "alpha_082": alpha_082, + "alpha_083": alpha_083, + "alpha_084": alpha_084, + "alpha_085": alpha_085, + "alpha_086": alpha_086, + "alpha_087": alpha_087, + "alpha_088": alpha_088, + "alpha_089": alpha_089, + "alpha_090": alpha_090, + "alpha_091": alpha_091, + "alpha_092": alpha_092, + "alpha_093": alpha_093, + "alpha_094": alpha_094, + "alpha_095": alpha_095, + "alpha_096": alpha_096, + "alpha_097": alpha_097, + "alpha_098": alpha_098, + "alpha_099": alpha_099, + "alpha_100": alpha_100, +} + +_PHASE4_INPUT_CATEGORIES = { + 1: "single", + 2: "pair", + 3: "triple", + 4: "quadruple", + 5: "quintuple", +} + + +def _build_phase4_formula_specs() -> dict[str, dict[str, Any]]: + specs: dict[str, dict[str, Any]] = {} + for alpha_id, function in _PHASE4_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_PHASE4_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_PHASE4_FORMULA_SPECS: Mapping[str, Mapping[str, Any]] = ( + _freeze_phase3_formula_specs(_build_phase4_formula_specs()) +) + + +def list_phase4_formulas() -> tuple[str, ...]: + """Return the frozen alpha051-alpha100 formula IDs in stable order.""" + return tuple(ALPHA158_PHASE4_FORMULA_SPECS) + + +def evaluate_phase4_formula(name: str, **inputs: pd.Series) -> pd.Series: + """Evaluate a Phase 4 formula with an exact, alignment-safe input contract.""" + if name not in ALPHA158_PHASE4_FORMULA_SPECS: + raise KeyError(f"formula {name!r} not registered") + + spec = ALPHA158_PHASE4_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 not primary.index.equals(inputs[field].index): + raise ValueError(f"{field} index must align with {primary_field}") + + function = _PHASE4_FORMULA_FUNCTIONS[name] + return function(*(inputs[field] for field in required_inputs)) + + __all__ = [ "rank", "delta", @@ -3309,6 +3446,11 @@ __all__ = [ "ALPHA158_PHASE3_FORMULA_SPECS", "list_phase3_formulas", "evaluate_phase3_formula", + "ALPHA158_PHASE4_FORMULA_CONTRACT_VERSION", + "ALPHA158_PHASE4_FORMULA_CATALOG_SHA256", + "ALPHA158_PHASE4_FORMULA_SPECS", + "list_phase4_formulas", + "evaluate_phase4_formula", "alpha_001", "alpha_002", "alpha_003", diff --git a/tests/test_alpha_factors.py b/tests/test_alpha_factors.py index de3044f..8552078 100644 --- a/tests/test_alpha_factors.py +++ b/tests/test_alpha_factors.py @@ -14,6 +14,9 @@ from quant_engine.alpha_factors import ( ALPHA158_PHASE3_FORMULA_CATALOG_SHA256, ALPHA158_PHASE3_FORMULA_CONTRACT_VERSION, ALPHA158_PHASE3_FORMULA_SPECS, + ALPHA158_PHASE4_FORMULA_CATALOG_SHA256, + ALPHA158_PHASE4_FORMULA_CONTRACT_VERSION, + ALPHA158_PHASE4_FORMULA_SPECS, alpha_001, alpha_002, alpha_003, @@ -175,9 +178,11 @@ from quant_engine.alpha_factors import ( evaluate_phase1_operator, evaluate_phase2_operator, evaluate_phase3_formula, + evaluate_phase4_formula, list_phase1_operators, list_phase2_operators, list_phase3_formulas, + list_phase4_formulas, correlation, covariance, decay_linear, @@ -1641,3 +1646,146 @@ def test_phase3_dispatch_rejects_implicit_series_alignment(): close=inputs["close"], volume=misaligned_volume, ) + + +# ── Alpha158 Phase 4: versioned alpha051-alpha100 formula contract ────────── + + +def test_phase4_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(51, 101)) + expected_fields = { + "name", + "contract_version", + "formula", + "category", + "complexity", + "parameters", + "description", + "references", + "call_inputs", + "formula_inputs", + "input_category", + } + + assert ALPHA158_PHASE4_FORMULA_CONTRACT_VERSION == "1.0.0" + assert list_phase4_formulas() == expected_ids + assert tuple(ALPHA158_PHASE4_FORMULA_SPECS) == expected_ids + assert Counter( + spec["input_category"] for spec in ALPHA158_PHASE4_FORMULA_SPECS.values() + ) == {"single": 1, "pair": 27, "triple": 11, "quadruple": 10, "quintuple": 1} + + for alpha_id, spec in ALPHA158_PHASE4_FORMULA_SPECS.items(): + assert set(spec) == expected_fields + assert spec["name"] == alpha_id + assert spec["contract_version"] == ALPHA158_PHASE4_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_PHASE4_FORMULA_SPECS.items() + } + encoded = json.dumps( + serializable_specs, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + ).encode() + assert hashlib.sha256(encoded).hexdigest() == ALPHA158_PHASE4_FORMULA_CATALOG_SHA256 + assert ALPHA158_PHASE4_FORMULA_CATALOG_SHA256 == ( + "858daf5e2abab5063fc28fcf7c79936096e2c76dde582fc7bab78b3458f17054" + ) + + +def test_phase4_formula_catalog_is_recursively_immutable(): + import operator + + with pytest.raises(TypeError): + operator.setitem(ALPHA158_PHASE4_FORMULA_SPECS, "alpha_051", {}) + with pytest.raises(TypeError): + operator.setitem( + ALPHA158_PHASE4_FORMULA_SPECS["alpha_051"], + "formula", + "changed", + ) + with pytest.raises(TypeError): + operator.setitem( + ALPHA158_PHASE4_FORMULA_SPECS["alpha_051"]["call_inputs"], + 0, + "volume", + ) + + +def test_phase4_catalog_freezes_callable_and_formula_inputs(): + import inspect + + for alpha_id, spec in ALPHA158_PHASE4_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_PHASE4_FORMULA_SPECS["alpha_055"]["call_inputs"] == ( + "open", + "high", + "low", + "volume", + "close", + ) + + +def test_phase4_dispatch_matches_all_existing_alpha051_alpha100_functions(): + inputs = _phase3_market_inputs() + + for alpha_id, spec in ALPHA158_PHASE4_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_phase4_formula( + alpha_id, + **{name: inputs[name] for name in reversed(call_inputs)}, + ) + pd.testing.assert_series_equal(actual, expected) + + +def test_phase4_dispatch_rejects_unknown_missing_extra_and_non_series_inputs(): + inputs = _phase3_market_inputs() + + with pytest.raises(KeyError, match="not registered"): + evaluate_phase4_formula("alpha_050", close=inputs["close"]) + with pytest.raises(ValueError, match=r"missing inputs.*low"): + evaluate_phase4_formula("alpha_051", high=inputs["high"]) + with pytest.raises(ValueError, match=r"unexpected inputs.*vwap"): + evaluate_phase4_formula( + "alpha_051", + high=inputs["high"], + low=inputs["low"], + vwap=inputs["vwap"], + ) + with pytest.raises(TypeError, match="high must be a pandas Series"): + evaluate_phase4_formula( # type: ignore[arg-type] + "alpha_051", + high=[1.0, 2.0], + low=inputs["low"], + ) + + +def test_phase4_dispatch_rejects_implicit_series_alignment(): + inputs = _phase3_market_inputs() + misaligned_low = inputs["low"].rename(index={79: 80}) + + with pytest.raises(ValueError, match="low index must align with high"): + evaluate_phase4_formula( + "alpha_051", + high=inputs["high"], + low=misaligned_low, + )