Merge remote-tracking branch 'origin/main' into codex/research-alpha158-phase2-20260827 #9
@@ -344,6 +344,233 @@ def evaluate_phase1_operator(
|
|||||||
return operator(series, window)
|
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 公式样本) ─────────────────────────
|
# ── 组合算子(alpha158 公式样本) ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -2894,6 +3121,10 @@ __all__ = [
|
|||||||
"ALPHA158_PHASE1_OPERATOR_SPECS",
|
"ALPHA158_PHASE1_OPERATOR_SPECS",
|
||||||
"list_phase1_operators",
|
"list_phase1_operators",
|
||||||
"evaluate_phase1_operator",
|
"evaluate_phase1_operator",
|
||||||
|
"ALPHA158_PHASE2_MAX_WINDOW",
|
||||||
|
"ALPHA158_PHASE2_OPERATOR_SPECS",
|
||||||
|
"list_phase2_operators",
|
||||||
|
"evaluate_phase2_operator",
|
||||||
"alpha_001",
|
"alpha_001",
|
||||||
"alpha_002",
|
"alpha_002",
|
||||||
"alpha_003",
|
"alpha_003",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import pytest
|
|||||||
from quant_engine.alpha_factors import (
|
from quant_engine.alpha_factors import (
|
||||||
ALPHA158_REGISTRY,
|
ALPHA158_REGISTRY,
|
||||||
ALPHA158_PHASE1_OPERATOR_SPECS,
|
ALPHA158_PHASE1_OPERATOR_SPECS,
|
||||||
|
ALPHA158_PHASE2_OPERATOR_SPECS,
|
||||||
alpha_001,
|
alpha_001,
|
||||||
alpha_002,
|
alpha_002,
|
||||||
alpha_003,
|
alpha_003,
|
||||||
@@ -168,7 +169,9 @@ from quant_engine.alpha_factors import (
|
|||||||
alpha_157,
|
alpha_157,
|
||||||
alpha_158,
|
alpha_158,
|
||||||
evaluate_phase1_operator,
|
evaluate_phase1_operator,
|
||||||
|
evaluate_phase2_operator,
|
||||||
list_phase1_operators,
|
list_phase1_operators,
|
||||||
|
list_phase2_operators,
|
||||||
correlation,
|
correlation,
|
||||||
covariance,
|
covariance,
|
||||||
decay_linear,
|
decay_linear,
|
||||||
@@ -1328,3 +1331,135 @@ def test_phase1_dispatch_rejects_window_above_supported_limit():
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="maximum"):
|
with pytest.raises(ValueError, match="maximum"):
|
||||||
evaluate_phase1_operator("ts_mean", values, window=2**63)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user