From fd3014c286ea2b0759ddc0f3b045f5560d700c74 Mon Sep 17 00:00:00 2001 From: ageorge156 Date: Fri, 28 Aug 2026 18:26:14 +0800 Subject: [PATCH 1/2] fix: bound phase1 operator windows (#8) --- src/quant_engine/alpha_factors.py | 139 ++++++++++++++++++++++++++++++ tests/test_alpha_factors.py | 96 +++++++++++++++++++++ 2 files changed, 235 insertions(+) diff --git a/src/quant_engine/alpha_factors.py b/src/quant_engine/alpha_factors.py index 3926ce2..2079417 100644 --- a/src/quant_engine/alpha_factors.py +++ b/src/quant_engine/alpha_factors.py @@ -15,6 +15,7 @@ v1.2.0 Phase 0:5 个基础算子 + 5 个 alpha 公式(alpha001–alpha005) from __future__ import annotations +from collections.abc import Callable from typing import Any import numpy as np @@ -209,6 +210,140 @@ def indneutralize(series: pd.Series, groups: pd.Series) -> pd.Series: return series - series.groupby(groups).transform("mean") +# ── Phase 1 operator contract ────────────────────────── + +# This is deliberately a small, stable surface for downstream research +# orchestration. The full alpha158 formula catalogue can continue to grow, +# while callers use one validated dispatch entry point for the first ten +# deterministic building blocks. +ALPHA158_PHASE1_MAX_WINDOW = 252 + +ALPHA158_PHASE1_OPERATOR_SPECS: dict[str, dict[str, Any]] = { + "rank": { + "name": "rank", + "formula": "rank(series)", + "inputs": ["series"], + "windowed": False, + }, + "delta": { + "name": "delta", + "formula": "delta(series, window)", + "inputs": ["series"], + "windowed": True, + }, + "ts_mean": { + "name": "ts_mean", + "formula": "ts_mean(series, window)", + "inputs": ["series"], + "windowed": True, + }, + "ts_std": { + "name": "ts_std", + "formula": "ts_std(series, window)", + "inputs": ["series"], + "windowed": True, + }, + "ts_rank": { + "name": "ts_rank", + "formula": "ts_rank(series, window)", + "inputs": ["series"], + "windowed": True, + }, + "correlation": { + "name": "correlation", + "formula": "correlation(series, secondary, window)", + "inputs": ["series", "secondary"], + "windowed": True, + }, + "ts_min": { + "name": "ts_min", + "formula": "ts_min(series, window)", + "inputs": ["series"], + "windowed": True, + }, + "ts_max": { + "name": "ts_max", + "formula": "ts_max(series, window)", + "inputs": ["series"], + "windowed": True, + }, + "ts_sum": { + "name": "ts_sum", + "formula": "ts_sum(series, window)", + "inputs": ["series"], + "windowed": True, + }, + "decay_linear": { + "name": "decay_linear", + "formula": "decay_linear(series, window)", + "inputs": ["series"], + "windowed": True, + }, +} + +_PHASE1_OPERATOR_FUNCTIONS: dict[str, Callable[..., pd.Series]] = { + "rank": rank, + "delta": delta, + "ts_mean": ts_mean, + "ts_std": ts_std, + "ts_rank": ts_rank, + "correlation": correlation, + "ts_min": ts_min, + "ts_max": ts_max, + "ts_sum": ts_sum, + "decay_linear": decay_linear, +} + + +def list_phase1_operators() -> tuple[str, ...]: + """Return the deterministic Phase 1 operator names in stable order.""" + return tuple(ALPHA158_PHASE1_OPERATOR_SPECS) + + +def evaluate_phase1_operator( + name: str, + series: pd.Series, + secondary: pd.Series | None = None, + *, + window: int | None = None, +) -> pd.Series: + """Evaluate one of the ten Phase 1 operators with a validated contract. + + ``window`` is required for time-series operators and forbidden for the + cross-sectional ``rank`` operator. Binary ``correlation`` also requires + a same-index secondary series so that callers cannot silently introduce + alignment-dependent results. + """ + if name not in ALPHA158_PHASE1_OPERATOR_SPECS: + raise KeyError(f"operator {name!r} not registered") + + is_windowed = bool(ALPHA158_PHASE1_OPERATOR_SPECS[name]["windowed"]) + if is_windowed: + 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_PHASE1_MAX_WINDOW: + raise ValueError( + f"window exceeds maximum supported value {ALPHA158_PHASE1_MAX_WINDOW} for {name}" + ) + if not is_windowed and window is not None: + raise ValueError(f"window is not supported for {name}") + + if name == "correlation": + if secondary is None: + raise ValueError("secondary is required for correlation") + if not series.index.equals(secondary.index): + raise ValueError("secondary index must align with series") + return correlation(series, secondary, window) # type: ignore[arg-type] + + if secondary is not None: + raise ValueError(f"secondary is not supported for {name}") + + operator = _PHASE1_OPERATOR_FUNCTIONS[name] + if name == "rank": + return operator(series) + return operator(series, window) + + # ── 组合算子(alpha158 公式样本) ───────────────────────── @@ -2755,6 +2890,10 @@ __all__ = [ "max_pair", "min_pair", "indneutralize", + "ALPHA158_PHASE1_MAX_WINDOW", + "ALPHA158_PHASE1_OPERATOR_SPECS", + "list_phase1_operators", + "evaluate_phase1_operator", "alpha_001", "alpha_002", "alpha_003", diff --git a/tests/test_alpha_factors.py b/tests/test_alpha_factors.py index ddfef21..6c3fc09 100644 --- a/tests/test_alpha_factors.py +++ b/tests/test_alpha_factors.py @@ -8,6 +8,7 @@ import pytest from quant_engine.alpha_factors import ( ALPHA158_REGISTRY, + ALPHA158_PHASE1_OPERATOR_SPECS, alpha_001, alpha_002, alpha_003, @@ -166,6 +167,8 @@ from quant_engine.alpha_factors import ( alpha_156, alpha_157, alpha_158, + evaluate_phase1_operator, + list_phase1_operators, correlation, covariance, decay_linear, @@ -1232,3 +1235,96 @@ def test_parse_alpha_formula_round_trip_jsonb(): serialized = json.dumps(parsed) assert isinstance(serialized, str) assert "ts_rank" in serialized + + +# ── v1.2.0 Phase 1: deterministic operator dispatch contract ────────────── + + +def test_phase1_operator_catalog_is_explicit_and_serializable(): + """Phase 1 exposes a stable, JSON-friendly catalog for downstream callers.""" + import json + + expected = { + "rank", + "delta", + "ts_mean", + "ts_std", + "ts_rank", + "correlation", + "ts_min", + "ts_max", + "ts_sum", + "decay_linear", + } + assert set(list_phase1_operators()) == expected + assert set(ALPHA158_PHASE1_OPERATOR_SPECS) == expected + json.dumps(ALPHA158_PHASE1_OPERATOR_SPECS) + for name, spec in ALPHA158_PHASE1_OPERATOR_SPECS.items(): + assert spec["name"] == name + assert isinstance(spec["inputs"], list) + assert isinstance(spec["formula"], str) + + +def test_phase1_unary_operators_preserve_index_and_are_deterministic(): + values = pd.Series([1.0, 2.0, 3.0, 4.0], index=["a", "b", "c", "d"]) + + first = evaluate_phase1_operator("rank", values) + second = evaluate_phase1_operator("rank", values) + + pd.testing.assert_series_equal(first, second) + assert first.index.equals(values.index) + assert first.iloc[-1] == pytest.approx(1.0) + + +@pytest.mark.parametrize( + ("name", "window"), + [ + ("delta", 2), + ("ts_mean", 2), + ("ts_std", 2), + ("ts_rank", 2), + ("ts_min", 2), + ("ts_max", 2), + ("ts_sum", 2), + ("decay_linear", 2), + ], +) +def test_phase1_windowed_operators_require_explicit_window(name: str, window: int): + values = pd.Series([1.0, 2.0, 3.0, 4.0]) + + result = evaluate_phase1_operator(name, values, window=window) + + assert result.index.equals(values.index) + with pytest.raises(ValueError, match="window"): + evaluate_phase1_operator(name, values) + with pytest.raises(ValueError, match="positive integer"): + evaluate_phase1_operator(name, values, window=1.5) # type: ignore[arg-type] + + +def test_phase1_binary_correlation_requires_aligned_secondary_input(): + values = pd.Series([1.0, 2.0, 3.0, 4.0]) + other = pd.Series([4.0, 3.0, 2.0, 1.0]) + + result = evaluate_phase1_operator("correlation", values, other, window=2) + + assert result.iloc[-1] == pytest.approx(-1.0) + with pytest.raises(ValueError, match="secondary"): + evaluate_phase1_operator("correlation", values, window=2) + + +def test_phase1_dispatch_rejects_unknown_or_unused_arguments(): + values = pd.Series([1.0, 2.0, 3.0]) + + with pytest.raises(KeyError, match="not registered"): + evaluate_phase1_operator("unknown", values) + with pytest.raises(ValueError, match="window"): + evaluate_phase1_operator("rank", values, window=2) + with pytest.raises(ValueError, match="secondary"): + evaluate_phase1_operator("rank", values, values) + + +def test_phase1_dispatch_rejects_window_above_supported_limit(): + values = pd.Series([1.0, 2.0, 3.0]) + + with pytest.raises(ValueError, match="maximum"): + evaluate_phase1_operator("ts_mean", values, window=2**63) From e72fe0a8d1451cc90d06dbc5b0621e42ba348e5c Mon Sep 17 00:00:00 2001 From: ageorge156 Date: Fri, 28 Aug 2026 18:28:22 +0800 Subject: [PATCH 2/2] Merge remote-tracking branch 'origin/main' into codex/research-alpha158-phase2-20260827 (#9) --- src/quant_engine/alpha_factors.py | 231 ++++++++++++++++++++++++++++++ tests/test_alpha_factors.py | 135 +++++++++++++++++ 2 files changed, 366 insertions(+) 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)