Merge remote-tracking branch 'origin/main' into codex/research-alpha158-phase4-formula-con #11
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user