From eca4bd4d658c47b994378ed3f5771d1371471c09 Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:20:19 +0800 Subject: [PATCH] feat: add phase1 alpha operator contract --- src/quant_engine/alpha_factors.py | 136 ++++++++++++++++++++++++++++++ tests/test_alpha_factors.py | 89 +++++++++++++++++++ 2 files changed, 225 insertions(+) diff --git a/src/quant_engine/alpha_factors.py b/src/quant_engine/alpha_factors.py index 3926ce2..eaa15ea 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,138 @@ 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_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 and ( + window is None + or isinstance(window, bool) + or not isinstance(window, int) + or window <= 0 + ): + raise ValueError(f"window must be a positive integer 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 +2888,9 @@ __all__ = [ "max_pair", "min_pair", "indneutralize", + "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..78cfe2a 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,89 @@ 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)