diff --git a/src/quant_engine/alpha_factors.py b/src/quant_engine/alpha_factors.py index 2079417..4ed8773 100644 --- a/src/quant_engine/alpha_factors.py +++ b/src/quant_engine/alpha_factors.py @@ -344,6 +344,233 @@ def evaluate_phase1_operator( return operator(series, window) +# ── Phase 2 cumulative operator contract ────────────────────────────── + +# Phase 2 is cumulative: downstream callers can upgrade to one dispatch +# surface covering every existing alpha158 building block, while Phase 1 +# names, metadata, ordering, and evaluation remain unchanged. +ALPHA158_PHASE2_MAX_WINDOW = ALPHA158_PHASE1_MAX_WINDOW + +ALPHA158_PHASE2_OPERATOR_SPECS: dict[str, dict[str, Any]] = { + name: { + **spec, + "parameters": ["window"] if bool(spec["windowed"]) else [], + } + for name, spec in ALPHA158_PHASE1_OPERATOR_SPECS.items() +} +ALPHA158_PHASE2_OPERATOR_SPECS.update( + { + "ts_argmin": { + "name": "ts_argmin", + "formula": "ts_argmin(series, window)", + "inputs": ["series"], + "parameters": ["window"], + "windowed": True, + }, + "ts_argmax": { + "name": "ts_argmax", + "formula": "ts_argmax(series, window)", + "inputs": ["series"], + "parameters": ["window"], + "windowed": True, + }, + "product": { + "name": "product", + "formula": "product(series, window)", + "inputs": ["series"], + "parameters": ["window"], + "windowed": True, + }, + "returns": { + "name": "returns", + "formula": "returns(series)", + "inputs": ["series"], + "parameters": [], + "windowed": False, + }, + "scale": { + "name": "scale", + "formula": "scale(series)", + "inputs": ["series"], + "parameters": [], + "windowed": False, + }, + "signed_power": { + "name": "signed_power", + "formula": "signed_power(series, exponent)", + "inputs": ["series"], + "parameters": ["exponent"], + "windowed": False, + }, + "stddev": { + "name": "stddev", + "formula": "stddev(series, window)", + "inputs": ["series"], + "parameters": ["window"], + "windowed": True, + }, + "covariance": { + "name": "covariance", + "formula": "covariance(series, secondary, window)", + "inputs": ["series", "secondary"], + "parameters": ["window"], + "windowed": True, + }, + "log": { + "name": "log", + "formula": "log(series)", + "inputs": ["series"], + "parameters": [], + "windowed": False, + }, + "abs_series": { + "name": "abs_series", + "formula": "abs_series(series)", + "inputs": ["series"], + "parameters": [], + "windowed": False, + }, + "sign": { + "name": "sign", + "formula": "sign(series)", + "inputs": ["series"], + "parameters": [], + "windowed": False, + }, + "max_pair": { + "name": "max_pair", + "formula": "max_pair(series, secondary)", + "inputs": ["series", "secondary"], + "parameters": [], + "windowed": False, + }, + "min_pair": { + "name": "min_pair", + "formula": "min_pair(series, secondary)", + "inputs": ["series", "secondary"], + "parameters": [], + "windowed": False, + }, + "indneutralize": { + "name": "indneutralize", + "formula": "indneutralize(series, groups)", + "inputs": ["series", "groups"], + "parameters": [], + "windowed": False, + }, + } +) + +_PHASE2_OPERATOR_FUNCTIONS: dict[str, Callable[..., pd.Series]] = { + **_PHASE1_OPERATOR_FUNCTIONS, + "ts_argmin": ts_argmin, + "ts_argmax": ts_argmax, + "product": product, + "returns": returns, + "scale": scale, + "signed_power": signed_power, + "stddev": stddev, + "covariance": covariance, + "log": log, + "abs_series": abs_series, + "sign": sign, + "max_pair": max_pair, + "min_pair": min_pair, + "indneutralize": indneutralize, +} + +_PHASE2_WINDOWED_OPERATORS = frozenset( + name for name, spec in ALPHA158_PHASE2_OPERATOR_SPECS.items() if bool(spec["windowed"]) +) +_PHASE2_BINARY_OPERATORS = frozenset({"correlation", "covariance", "max_pair", "min_pair"}) + + +def list_phase2_operators() -> tuple[str, ...]: + """Return all Phase 2 operator names in stable cumulative order.""" + return tuple(ALPHA158_PHASE2_OPERATOR_SPECS) + + +def _validate_phase2_window(name: str, window: int | None) -> int: + if isinstance(window, bool) or not isinstance(window, int) or window <= 0: + raise ValueError(f"window must be a positive integer for {name}") + if window > ALPHA158_PHASE2_MAX_WINDOW: + raise ValueError( + f"window exceeds maximum supported value {ALPHA158_PHASE2_MAX_WINDOW} for {name}" + ) + return window + + +def evaluate_phase2_operator( + name: str, + series: pd.Series, + secondary: pd.Series | None = None, + *, + window: int | None = None, + exponent: float | None = None, + groups: pd.Series | None = None, +) -> pd.Series: + """Evaluate any existing alpha158 building block through a strict contract. + + Phase 2 rejects implicit alignment, missing required arguments, unused + arguments, unbounded windows, and non-finite exponents before dispatch. + """ + if name not in ALPHA158_PHASE2_OPERATOR_SPECS: + raise KeyError(f"operator {name!r} not registered") + if not isinstance(series, pd.Series): + raise TypeError("series must be a pandas Series") + + validated_window: int | None = None + if name in _PHASE2_WINDOWED_OPERATORS: + validated_window = _validate_phase2_window(name, window) + elif window is not None: + raise ValueError(f"window is not supported for {name}") + + if name in _PHASE2_BINARY_OPERATORS: + if secondary is None: + raise ValueError(f"secondary is required for {name}") + if not isinstance(secondary, pd.Series): + raise TypeError("secondary must be a pandas Series") + if not series.index.equals(secondary.index): + raise ValueError("secondary index must align with series") + elif secondary is not None: + raise ValueError(f"secondary is not supported for {name}") + + validated_exponent: float | None = None + if name == "signed_power": + if ( + isinstance(exponent, bool) + or not isinstance(exponent, (int, float)) + or not np.isfinite(exponent) + ): + raise ValueError("exponent must be a finite number for signed_power") + validated_exponent = float(exponent) + elif exponent is not None: + raise ValueError(f"exponent is not supported for {name}") + + if name == "indneutralize": + if groups is None: + raise ValueError("groups is required for indneutralize") + if not isinstance(groups, pd.Series): + raise TypeError("groups must be a pandas Series") + if not series.index.equals(groups.index): + raise ValueError("groups index must align with series") + elif groups is not None: + raise ValueError(f"groups is not supported for {name}") + + operator = _PHASE2_OPERATOR_FUNCTIONS[name] + if name == "signed_power": + return operator(series, validated_exponent) + if name == "indneutralize": + return operator(series, groups) + if name in {"correlation", "covariance"}: + return operator(series, secondary, validated_window) + if name in {"max_pair", "min_pair"}: + return operator(series, secondary) + if validated_window is not None: + return operator(series, validated_window) + return operator(series) + + # ── 组合算子(alpha158 公式样本) ───────────────────────── @@ -2894,6 +3121,10 @@ __all__ = [ "ALPHA158_PHASE1_OPERATOR_SPECS", "list_phase1_operators", "evaluate_phase1_operator", + "ALPHA158_PHASE2_MAX_WINDOW", + "ALPHA158_PHASE2_OPERATOR_SPECS", + "list_phase2_operators", + "evaluate_phase2_operator", "alpha_001", "alpha_002", "alpha_003", diff --git a/tests/test_alpha_factors.py b/tests/test_alpha_factors.py index 6c3fc09..4174d3d 100644 --- a/tests/test_alpha_factors.py +++ b/tests/test_alpha_factors.py @@ -9,6 +9,7 @@ import pytest from quant_engine.alpha_factors import ( ALPHA158_REGISTRY, ALPHA158_PHASE1_OPERATOR_SPECS, + ALPHA158_PHASE2_OPERATOR_SPECS, alpha_001, alpha_002, alpha_003, @@ -168,7 +169,9 @@ from quant_engine.alpha_factors import ( alpha_157, alpha_158, evaluate_phase1_operator, + evaluate_phase2_operator, list_phase1_operators, + list_phase2_operators, correlation, covariance, decay_linear, @@ -1328,3 +1331,135 @@ def test_phase1_dispatch_rejects_window_above_supported_limit(): with pytest.raises(ValueError, match="maximum"): evaluate_phase1_operator("ts_mean", values, window=2**63) + + +# ── v1.2.0 Phase 2: cumulative deterministic operator contract ───────────── + + +def test_phase2_operator_catalog_is_cumulative_stable_and_serializable(): + """Phase 2 exposes all existing building blocks without changing Phase 1.""" + import json + + phase1 = list_phase1_operators() + expected_phase2 = ( + *phase1, + "ts_argmin", + "ts_argmax", + "product", + "returns", + "scale", + "signed_power", + "stddev", + "covariance", + "log", + "abs_series", + "sign", + "max_pair", + "min_pair", + "indneutralize", + ) + + assert list_phase2_operators() == expected_phase2 + assert tuple(ALPHA158_PHASE2_OPERATOR_SPECS) == expected_phase2 + assert tuple(ALPHA158_PHASE1_OPERATOR_SPECS) == phase1 + json.dumps(ALPHA158_PHASE2_OPERATOR_SPECS) + for name, spec in ALPHA158_PHASE2_OPERATOR_SPECS.items(): + assert spec["name"] == name + assert isinstance(spec["inputs"], list) + assert isinstance(spec["parameters"], list) + assert isinstance(spec["formula"], str) + + +@pytest.mark.parametrize("name", ["ts_argmin", "ts_argmax", "product", "stddev"]) +def test_phase2_windowed_unary_dispatch_is_deterministic(name: str): + values = pd.Series([3.0, 1.0, 4.0, 2.0], index=["a", "b", "c", "d"]) + + first = evaluate_phase2_operator(name, values, window=3) + second = evaluate_phase2_operator(name, values, window=3) + + pd.testing.assert_series_equal(first, second) + assert first.index.equals(values.index) + with pytest.raises(ValueError, match="window"): + evaluate_phase2_operator(name, values) + + +@pytest.mark.parametrize("name", ["returns", "scale", "log", "abs_series", "sign"]) +def test_phase2_unary_dispatch_rejects_unused_arguments(name: str): + values = pd.Series([1.0, 2.0, 4.0], index=["a", "b", "c"]) + + result = evaluate_phase2_operator(name, values) + + assert result.index.equals(values.index) + with pytest.raises(ValueError, match="window"): + evaluate_phase2_operator(name, values, window=2) + with pytest.raises(ValueError, match="secondary"): + evaluate_phase2_operator(name, values, secondary=values) + + +@pytest.mark.parametrize( + ("name", "window"), + [("correlation", 2), ("covariance", 2), ("max_pair", None), ("min_pair", None)], +) +def test_phase2_binary_dispatch_requires_aligned_secondary(name: str, window: int | None): + values = pd.Series([1.0, 2.0, 3.0], index=["a", "b", "c"]) + secondary = pd.Series([3.0, 2.0, 1.0], index=values.index) + + result = evaluate_phase2_operator(name, values, secondary=secondary, window=window) + + assert result.index.equals(values.index) + with pytest.raises(ValueError, match="secondary is required"): + evaluate_phase2_operator(name, values, window=window) + with pytest.raises(ValueError, match="secondary index"): + evaluate_phase2_operator( + name, + values, + secondary=secondary.rename(index={"c": "z"}), + window=window, + ) + + +def test_phase2_signed_power_requires_finite_numeric_exponent(): + values = pd.Series([-4.0, 0.0, 9.0]) + + result = evaluate_phase2_operator("signed_power", values, exponent=0.5) + + pd.testing.assert_series_equal(result, pd.Series([-2.0, 0.0, 3.0])) + for exponent in (None, True, float("inf"), float("nan"), "2"): + with pytest.raises(ValueError, match="exponent"): + evaluate_phase2_operator( # type: ignore[arg-type] + "signed_power", + values, + exponent=exponent, + ) + + +def test_phase2_indneutralize_requires_aligned_groups(): + values = pd.Series([1.0, 3.0, 10.0, 14.0], index=["a", "b", "c", "d"]) + groups = pd.Series(["x", "x", "y", "y"], index=values.index) + + result = evaluate_phase2_operator("indneutralize", values, groups=groups) + + pd.testing.assert_series_equal(result, pd.Series([-1.0, 1.0, -2.0, 2.0], index=values.index)) + with pytest.raises(ValueError, match="groups is required"): + evaluate_phase2_operator("indneutralize", values) + with pytest.raises(ValueError, match="groups index"): + evaluate_phase2_operator( + "indneutralize", + values, + groups=groups.rename(index={"d": "z"}), + ) + + +def test_phase2_dispatch_validates_primary_series_and_unused_parameters(): + values = pd.Series([1.0, 2.0, 3.0]) + + with pytest.raises(TypeError, match="series must be a pandas Series"): + evaluate_phase2_operator("rank", [1.0, 2.0, 3.0]) # type: ignore[arg-type] + with pytest.raises(KeyError, match="not registered"): + evaluate_phase2_operator("unknown", values) + with pytest.raises(ValueError, match="exponent"): + evaluate_phase2_operator("rank", values, exponent=2.0) + with pytest.raises(ValueError, match="groups"): + evaluate_phase2_operator("rank", values, groups=pd.Series(["x", "x", "x"])) + with pytest.raises(ValueError, match="maximum"): + evaluate_phase2_operator("product", values, window=253)