1644 lines
52 KiB
Python
1644 lines
52 KiB
Python
"""src/shared/alpha_factors.py 单元测试(v1.2.0 Phase 0 第一批)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
import pytest
|
||
|
||
import quant_engine.alpha_factors as alpha_factors_module
|
||
from quant_engine.alpha_factors import (
|
||
ALPHA158_REGISTRY,
|
||
ALPHA158_PHASE1_OPERATOR_SPECS,
|
||
ALPHA158_PHASE2_OPERATOR_SPECS,
|
||
ALPHA158_PHASE3_FORMULA_CATALOG_SHA256,
|
||
ALPHA158_PHASE3_FORMULA_CONTRACT_VERSION,
|
||
ALPHA158_PHASE3_FORMULA_SPECS,
|
||
alpha_001,
|
||
alpha_002,
|
||
alpha_003,
|
||
alpha_004,
|
||
alpha_005,
|
||
alpha_006,
|
||
alpha_007,
|
||
alpha_008,
|
||
alpha_009,
|
||
alpha_010,
|
||
alpha_011,
|
||
alpha_012,
|
||
alpha_013,
|
||
alpha_014,
|
||
alpha_015,
|
||
alpha_016,
|
||
alpha_017,
|
||
alpha_018,
|
||
alpha_019,
|
||
alpha_020,
|
||
alpha_021,
|
||
alpha_022,
|
||
alpha_023,
|
||
alpha_024,
|
||
alpha_025,
|
||
alpha_026,
|
||
alpha_027,
|
||
alpha_028,
|
||
alpha_029,
|
||
alpha_030,
|
||
alpha_031,
|
||
alpha_032,
|
||
alpha_033,
|
||
alpha_034,
|
||
alpha_035,
|
||
alpha_036,
|
||
alpha_037,
|
||
alpha_038,
|
||
alpha_039,
|
||
alpha_040,
|
||
alpha_041,
|
||
alpha_042,
|
||
alpha_043,
|
||
alpha_044,
|
||
alpha_045,
|
||
alpha_046,
|
||
alpha_047,
|
||
alpha_048,
|
||
alpha_049,
|
||
alpha_050,
|
||
alpha_051,
|
||
alpha_052,
|
||
alpha_053,
|
||
alpha_054,
|
||
alpha_055,
|
||
alpha_056,
|
||
alpha_057,
|
||
alpha_058,
|
||
alpha_059,
|
||
alpha_060,
|
||
alpha_061,
|
||
alpha_062,
|
||
alpha_063,
|
||
alpha_064,
|
||
alpha_065,
|
||
alpha_066,
|
||
alpha_067,
|
||
alpha_068,
|
||
alpha_069,
|
||
alpha_070,
|
||
alpha_071,
|
||
alpha_072,
|
||
alpha_073,
|
||
alpha_074,
|
||
alpha_075,
|
||
alpha_076,
|
||
alpha_077,
|
||
alpha_078,
|
||
alpha_079,
|
||
alpha_080,
|
||
alpha_081,
|
||
alpha_082,
|
||
alpha_083,
|
||
alpha_084,
|
||
alpha_085,
|
||
alpha_086,
|
||
alpha_087,
|
||
alpha_088,
|
||
alpha_089,
|
||
alpha_090,
|
||
alpha_091,
|
||
alpha_092,
|
||
alpha_093,
|
||
alpha_094,
|
||
alpha_095,
|
||
alpha_096,
|
||
alpha_097,
|
||
alpha_098,
|
||
alpha_099,
|
||
alpha_100,
|
||
alpha_101,
|
||
alpha_102,
|
||
alpha_103,
|
||
alpha_104,
|
||
alpha_105,
|
||
alpha_106,
|
||
alpha_107,
|
||
alpha_108,
|
||
alpha_109,
|
||
alpha_110,
|
||
alpha_111,
|
||
alpha_112,
|
||
alpha_113,
|
||
alpha_114,
|
||
alpha_115,
|
||
alpha_116,
|
||
alpha_117,
|
||
alpha_118,
|
||
alpha_119,
|
||
alpha_120,
|
||
alpha_121,
|
||
alpha_122,
|
||
alpha_123,
|
||
alpha_124,
|
||
alpha_125,
|
||
alpha_126,
|
||
alpha_127,
|
||
alpha_128,
|
||
alpha_129,
|
||
alpha_130,
|
||
alpha_131,
|
||
alpha_132,
|
||
alpha_133,
|
||
alpha_134,
|
||
alpha_135,
|
||
alpha_136,
|
||
alpha_137,
|
||
alpha_138,
|
||
alpha_139,
|
||
alpha_140,
|
||
alpha_141,
|
||
alpha_142,
|
||
alpha_143,
|
||
alpha_144,
|
||
alpha_145,
|
||
alpha_146,
|
||
alpha_147,
|
||
alpha_148,
|
||
alpha_149,
|
||
alpha_150,
|
||
alpha_151,
|
||
alpha_152,
|
||
alpha_153,
|
||
alpha_154,
|
||
alpha_155,
|
||
alpha_156,
|
||
alpha_157,
|
||
alpha_158,
|
||
evaluate_phase1_operator,
|
||
evaluate_phase2_operator,
|
||
evaluate_phase3_formula,
|
||
list_phase1_operators,
|
||
list_phase2_operators,
|
||
list_phase3_formulas,
|
||
correlation,
|
||
covariance,
|
||
decay_linear,
|
||
delta,
|
||
get_alpha_meta,
|
||
indneutralize,
|
||
log,
|
||
max_pair,
|
||
min_pair,
|
||
product,
|
||
rank,
|
||
returns,
|
||
scale,
|
||
sign,
|
||
signed_power,
|
||
stddev,
|
||
ts_argmax,
|
||
ts_argmin,
|
||
ts_max,
|
||
ts_mean,
|
||
ts_min,
|
||
ts_rank,
|
||
ts_std,
|
||
ts_sum,
|
||
)
|
||
|
||
|
||
# ── rank 基础算子 ──────────────────────────────
|
||
|
||
|
||
def test_rank_uniform_input():
|
||
"""全相同输入 → 所有位置相同(pandas average tie-breaking)。"""
|
||
s = pd.Series([1.0, 1.0, 1.0, 1.0])
|
||
result = rank(s)
|
||
# pandas rank default method='average': ties get average rank
|
||
# 4 ties at positions 1,2,3,4 → average 2.5 → 2.5/4 = 0.625
|
||
expected = pd.Series([0.625, 0.625, 0.625, 0.625])
|
||
pd.testing.assert_series_equal(result, expected)
|
||
|
||
|
||
def test_rank_basic():
|
||
"""5 个升序值 → 0.2, 0.4, 0.6, 0.8, 1.0(pandas rank pct=True)。"""
|
||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
result = rank(s)
|
||
expected = pd.Series([0.2, 0.4, 0.6, 0.8, 1.0])
|
||
pd.testing.assert_series_equal(result, expected)
|
||
|
||
|
||
def test_rank_empty():
|
||
"""空 series 返回空 series。"""
|
||
s = pd.Series([], dtype=float)
|
||
result = rank(s)
|
||
assert len(result) == 0
|
||
|
||
|
||
# ── delta 基础算子 ──────────────────────────────
|
||
|
||
|
||
def test_delta_basic():
|
||
"""5 期 delta。"""
|
||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
|
||
result = delta(s, 5)
|
||
expected = pd.Series([np.nan, np.nan, np.nan, np.nan, np.nan, 5.0])
|
||
pd.testing.assert_series_equal(result, expected)
|
||
|
||
|
||
def test_delta_zero():
|
||
"""delta n=0 → 全部 NaN(无前移)。"""
|
||
s = pd.Series([1.0, 2.0, 3.0])
|
||
result = delta(s, 0)
|
||
# shift(0) is identity, so result is all zeros except first NaN
|
||
assert result.iloc[0] == 0.0 or pd.isna(result.iloc[0])
|
||
|
||
|
||
def test_delta_negative_raises():
|
||
"""delta n<0 应报错。"""
|
||
s = pd.Series([1.0, 2.0, 3.0])
|
||
with pytest.raises(ValueError, match="non-negative"):
|
||
delta(s, -1)
|
||
|
||
|
||
# ── ts_mean 基础算子 ──────────────────────────────
|
||
|
||
|
||
def test_ts_mean_basic():
|
||
"""20 期均值。"""
|
||
s = pd.Series(range(20), dtype=float)
|
||
result = ts_mean(s, 5)
|
||
# 最后 5 个值 15,16,17,18,19 均值 17
|
||
assert result.iloc[-1] == 17.0
|
||
|
||
|
||
def test_ts_mean_n_1_raises():
|
||
"""ts_mean n<=0 应报错。"""
|
||
s = pd.Series([1.0, 2.0, 3.0])
|
||
with pytest.raises(ValueError, match="positive"):
|
||
ts_mean(s, 0)
|
||
|
||
|
||
# ── ts_std 基础算子 ──────────────────────────────
|
||
|
||
|
||
def test_ts_std_basic():
|
||
"""常量 series → std = 0。"""
|
||
s = pd.Series([5.0] * 20)
|
||
result = ts_std(s, 5)
|
||
assert result.iloc[-1] == 0.0
|
||
|
||
|
||
def test_ts_std_n_1_raises():
|
||
"""ts_std n<=0 应报错。"""
|
||
s = pd.Series([1.0, 2.0, 3.0])
|
||
with pytest.raises(ValueError, match="positive"):
|
||
ts_std(s, -1)
|
||
|
||
|
||
# ── ts_rank 基础算子 ──────────────────────────────
|
||
|
||
|
||
def test_ts_rank_basic():
|
||
"""时序 rank:最后一个值在窗口内应排 1.0。"""
|
||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
result = ts_rank(s, 5)
|
||
# 最后一个值 5 在 [1,2,3,4,5] 内 rank = 1.0
|
||
assert result.iloc[-1] == 1.0
|
||
|
||
|
||
def test_ts_rank_n_1_raises():
|
||
"""ts_rank n<=0 应报错。"""
|
||
s = pd.Series([1.0, 2.0, 3.0])
|
||
with pytest.raises(ValueError, match="positive"):
|
||
ts_rank(s, 0)
|
||
|
||
|
||
# ── correlation 基础算子 ──────────────────────────────
|
||
|
||
|
||
def test_correlation_perfect_positive():
|
||
"""完全正相关 → 1.0。"""
|
||
s1 = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0])
|
||
s2 = s1 * 2 + 1
|
||
result = correlation(s1, s2, 5)
|
||
assert result.iloc[-1] == pytest.approx(1.0)
|
||
|
||
|
||
def test_correlation_perfect_negative():
|
||
"""完全负相关 → -1.0。"""
|
||
s1 = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0])
|
||
s2 = -s1
|
||
result = correlation(s1, s2, 5)
|
||
assert result.iloc[-1] == pytest.approx(-1.0)
|
||
|
||
|
||
def test_correlation_n_1_raises():
|
||
"""correlation n<=0 应报错。"""
|
||
s1 = pd.Series([1.0, 2.0, 3.0])
|
||
s2 = pd.Series([3.0, 2.0, 1.0])
|
||
with pytest.raises(ValueError, match="positive"):
|
||
correlation(s1, s2, 0)
|
||
|
||
|
||
# ── alpha_001 公式 ──────────────────────────────
|
||
|
||
|
||
def test_alpha_001_smoke():
|
||
"""alpha_001 端到端 smoke test。"""
|
||
idx = pd.MultiIndex.from_product(
|
||
[["d1", "d2", "d3", "d4", "d5"], ["s1", "s2", "s3"]],
|
||
names=["date", "stock"],
|
||
)
|
||
close = pd.Series(np.random.rand(15), index=idx)
|
||
result = alpha_001(close)
|
||
assert isinstance(result, pd.Series)
|
||
assert result.shape == close.shape
|
||
|
||
|
||
def test_alpha_001_constant_input():
|
||
"""常量输入 → ts_rank 在窗口内全 0.5 → rank 后仍全相同。"""
|
||
s = pd.Series([5.0] * 10)
|
||
result = alpha_001(s)
|
||
# 全 0.5 输入 → ts_rank = 0.5 → rank 在 6 个 0.5 之间取平均
|
||
# 6 个 ties at positions 1..6 → average 3.5 → 3.5/6 ≈ 0.5833
|
||
expected_value = 3.5 / 6.0
|
||
valid = result.dropna()
|
||
for v in valid:
|
||
assert v == pytest.approx(expected_value)
|
||
|
||
|
||
# ── alpha_002 公式 ──────────────────────────────
|
||
|
||
|
||
def test_alpha_002_matches_delta():
|
||
"""alpha_002 应等于 delta(close, 5)。"""
|
||
close = pd.Series(range(20), dtype=float)
|
||
assert alpha_002(close).equals(delta(close, 5))
|
||
|
||
|
||
# ── alpha_003 公式 ──────────────────────────────
|
||
|
||
|
||
def test_alpha_003_matches_ts_mean():
|
||
"""alpha_003 应等于 ts_mean(close, 20)。"""
|
||
close = pd.Series(range(30), dtype=float)
|
||
assert alpha_003(close).equals(ts_mean(close, 20))
|
||
|
||
|
||
# ── alpha_004 公式 ──────────────────────────────
|
||
|
||
|
||
def test_alpha_004_matches_ts_std():
|
||
"""alpha_004 应等于 ts_std(close, 20)。"""
|
||
close = pd.Series(range(30), dtype=float)
|
||
assert alpha_004(close).equals(ts_std(close, 20))
|
||
|
||
|
||
# ── alpha_005 公式 ──────────────────────────────
|
||
|
||
|
||
def test_alpha_005_matches_correlation():
|
||
"""alpha_005 应等于 correlation(close, volume, 10)。"""
|
||
close = pd.Series(range(20), dtype=float)
|
||
volume = pd.Series([1.0] * 10 + [2.0] * 10)
|
||
assert alpha_005(close, volume).equals(correlation(close, volume, 10))
|
||
|
||
|
||
# ── 元数据 ──────────────────────────────
|
||
|
||
|
||
def test_alpha_registry_complete():
|
||
"""158 个 alpha 算子全部在 registry 中(v1.2.0 第八批后——达成完整 alpha158)。"""
|
||
expected = {f"alpha_{i:03d}" for i in range(1, 159)}
|
||
assert set(ALPHA158_REGISTRY.keys()) == expected
|
||
|
||
|
||
def test_alpha_registry_required_fields():
|
||
"""每个 registry 条目都包含必需字段。"""
|
||
required = {"formula", "category", "complexity", "params", "description", "inputs"}
|
||
for alpha_id, meta in ALPHA158_REGISTRY.items():
|
||
assert required <= set(meta.keys()), f"{alpha_id} missing fields"
|
||
|
||
|
||
def test_get_alpha_meta_success():
|
||
"""已知 alpha_id 返回完整 meta。"""
|
||
meta = get_alpha_meta("alpha_001")
|
||
assert meta["formula"] == "rank(ts_rank(close, 5))"
|
||
assert meta["category"] == "momentum_rank"
|
||
|
||
|
||
def test_get_alpha_meta_unknown():
|
||
"""未知 alpha_id 抛 KeyError。"""
|
||
with pytest.raises(KeyError, match="alpha_999"):
|
||
get_alpha_meta("alpha_999")
|
||
|
||
|
||
def test_alpha_meta_jsonb_serializable():
|
||
"""元数据可以 JSON 序列化(PostgreSQL JSONB 落库前置验证)。"""
|
||
import json
|
||
|
||
for alpha_id, meta in ALPHA158_REGISTRY.items():
|
||
# 抛异常即失败
|
||
json.dumps(meta)
|
||
|
||
|
||
# ── v1.2.0 第二批基础算子(ts_min / ts_max / ts_sum / ts_argmin / ts_argmax / decay_linear / product / returns / scale / signed_power) ──
|
||
|
||
|
||
def test_ts_min_basic():
|
||
"""5 期滚动最小值。"""
|
||
s = pd.Series([5.0, 3.0, 8.0, 1.0, 6.0, 2.0])
|
||
assert ts_min(s, 5).iloc[-1] == 1.0
|
||
|
||
|
||
def test_ts_max_basic():
|
||
"""5 期滚动最大值。"""
|
||
s = pd.Series([5.0, 3.0, 8.0, 1.0, 6.0, 2.0])
|
||
assert ts_max(s, 5).iloc[-1] == 8.0
|
||
|
||
|
||
def test_ts_sum_basic():
|
||
"""5 期滚动求和。"""
|
||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
|
||
assert ts_sum(s, 5).iloc[-1] == 20.0 # 2+3+4+5+6
|
||
|
||
|
||
def test_ts_argmin_first_position():
|
||
"""最小值在窗口起点 → argmin = 0。"""
|
||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
assert ts_argmin(s, 5).iloc[-1] == 0.0
|
||
|
||
|
||
def test_ts_argmax_last_position():
|
||
"""最大值在窗口终点 → argmax = 4。"""
|
||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
assert ts_argmax(s, 5).iloc[-1] == 4.0
|
||
|
||
|
||
def test_decay_linear_recent_weighted():
|
||
"""线性衰减:近期权重大。"""
|
||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
weights = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
weights /= weights.sum() # [0.0667, 0.1333, 0.2, 0.2667, 0.3333]
|
||
expected = float(np.dot(s.values, weights))
|
||
assert decay_linear(s, 5).iloc[-1] == pytest.approx(expected)
|
||
|
||
|
||
def test_product_basic():
|
||
"""5 期滚动乘积。"""
|
||
s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
assert product(s, 5).iloc[-1] == 120.0
|
||
|
||
|
||
def test_returns_basic():
|
||
"""简单收益率。"""
|
||
s = pd.Series([100.0, 110.0, 121.0])
|
||
assert returns(s).iloc[1] == pytest.approx(0.1)
|
||
assert returns(s).iloc[2] == pytest.approx(0.1)
|
||
|
||
|
||
def test_scale_sum_to_one():
|
||
"""scale 让 |x| 之和 = 1。"""
|
||
s = pd.Series([1.0, -2.0, 3.0, -4.0])
|
||
result = scale(s)
|
||
assert result.abs().sum() == pytest.approx(1.0)
|
||
|
||
|
||
def test_scale_zero_input():
|
||
"""scale 全 0 输入 → 原样返回(避免除零)。"""
|
||
s = pd.Series([0.0, 0.0, 0.0])
|
||
result = scale(s)
|
||
assert (result == 0.0).all()
|
||
|
||
|
||
def test_signed_power_positive():
|
||
"""正数 signed_power(2) → x^2。"""
|
||
s = pd.Series([3.0, 4.0])
|
||
result = signed_power(s, 2)
|
||
assert result.iloc[0] == pytest.approx(9.0)
|
||
assert result.iloc[1] == pytest.approx(16.0)
|
||
|
||
|
||
def test_signed_power_negative():
|
||
"""负数 signed_power(2) → -|x|^2。"""
|
||
s = pd.Series([-3.0])
|
||
result = signed_power(s, 2)
|
||
assert result.iloc[0] == pytest.approx(-9.0)
|
||
|
||
|
||
def test_ts_min_n_1_raises():
|
||
"""ts_min n<=0 应报错。"""
|
||
with pytest.raises(ValueError, match="positive"):
|
||
ts_min(pd.Series([1.0]), 0)
|
||
|
||
|
||
def test_ts_max_n_1_raises():
|
||
with pytest.raises(ValueError, match="positive"):
|
||
ts_max(pd.Series([1.0]), -1)
|
||
|
||
|
||
def test_decay_linear_n_1_raises():
|
||
with pytest.raises(ValueError, match="positive"):
|
||
decay_linear(pd.Series([1.0]), 0)
|
||
|
||
|
||
# ── v1.2.0 第二批 alpha 公式(alpha_006 – alpha_015) ─────
|
||
|
||
|
||
def test_alpha_006_smoke():
|
||
"""alpha_006 = rank(close - open) smoke test。"""
|
||
open_ = pd.Series([10.0, 11.0, 12.0])
|
||
close = pd.Series([11.0, 10.0, 13.0]) # diff: +1, -1, +1
|
||
result = alpha_006(open_, close)
|
||
# diff 序列 [1, -1, 1] → pandas rank pct=True:ranks [2.5, 1, 2.5] → pct [0.833, 0.333, 0.833]
|
||
assert result.iloc[0] == pytest.approx(5.0 / 6)
|
||
assert result.iloc[1] == pytest.approx(1.0 / 3)
|
||
assert result.iloc[2] == pytest.approx(5.0 / 6)
|
||
|
||
|
||
def test_alpha_007_volume_trend():
|
||
"""alpha_007 = mean(volume, 7) - mean(volume, 14)。"""
|
||
volume = pd.Series(range(20), dtype=float)
|
||
result = alpha_007(volume)
|
||
expected_last = volume.iloc[-7:].mean() - volume.iloc[-14:].mean()
|
||
assert result.iloc[-1] == pytest.approx(expected_last)
|
||
|
||
|
||
def test_alpha_008_smoke():
|
||
"""alpha_008 = rank(delta(vwap, 5))。"""
|
||
vwap = pd.Series(range(20), dtype=float)
|
||
result = alpha_008(vwap)
|
||
assert isinstance(result, pd.Series)
|
||
|
||
|
||
def test_alpha_009_min_rank():
|
||
"""alpha_009 = rank(ts_min(low, 5))。"""
|
||
low = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
result = alpha_009(low)
|
||
# 5 期 ts_min 都 = 1.0 → rank 全相同
|
||
valid = result.dropna()
|
||
assert valid.nunique() == 1
|
||
|
||
|
||
def test_alpha_010_max_rank():
|
||
"""alpha_010 = rank(ts_max(high, 5))。"""
|
||
high = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
result = alpha_010(high)
|
||
# 5 期 ts_max 都 = 5.0 → rank 全相同
|
||
valid = result.dropna()
|
||
assert valid.nunique() == 1
|
||
|
||
|
||
def test_alpha_011_constant_range():
|
||
"""alpha_011 高=低时(无振幅)→ 返回 0(safe_diff 处理)。"""
|
||
open_ = pd.Series([10.0, 11.0])
|
||
close = pd.Series([10.0, 11.0])
|
||
high = pd.Series([10.0, 11.0])
|
||
low = pd.Series([10.0, 11.0])
|
||
result = alpha_011(open_, close, high, low)
|
||
assert (result == 0.0).all()
|
||
|
||
|
||
def test_alpha_011_bullish():
|
||
"""alpha_011 阳线 close > open → 正值(多头强度)。"""
|
||
open_ = pd.Series([10.0] * 5)
|
||
close = pd.Series([10.5] * 5)
|
||
high = pd.Series([11.0] * 5)
|
||
low = pd.Series([9.5] * 5)
|
||
result = alpha_011(open_, close, high, low)
|
||
# close - low = 1.0; high - close = 0.5; high - low = 1.5
|
||
# (1.0 - 0.5) / 1.5 = 0.333
|
||
for v in result:
|
||
assert v == pytest.approx(1.0 / 3.0)
|
||
|
||
|
||
def test_alpha_012_volume_rank_change():
|
||
"""alpha_012 = rank(volume) - rank(volume.shift(5))。"""
|
||
volume = pd.Series(range(20), dtype=float)
|
||
result = alpha_012(volume)
|
||
# 前 5 个应为 NaN
|
||
assert result.iloc[:5].isna().all()
|
||
|
||
|
||
def test_alpha_013_returns_rank_change():
|
||
"""alpha_013 = rank(returns) - rank(returns.shift(3))。"""
|
||
close = pd.Series(range(20), dtype=float)
|
||
result = alpha_013(close)
|
||
# 前 4 个 NaN(returns 第一行 + shift(3) 3 行)
|
||
assert result.iloc[:4].isna().all()
|
||
|
||
|
||
def test_alpha_014_combined_momentum():
|
||
"""alpha_014 = -rank(delta(returns(open_), 3)) * correlation(open_, volume, 10)。"""
|
||
open_ = pd.Series(range(20), dtype=float)
|
||
volume = pd.Series([1.0] * 20)
|
||
result = alpha_014(open_, volume)
|
||
assert isinstance(result, pd.Series)
|
||
|
||
|
||
def test_alpha_015_high_volume_combo():
|
||
"""alpha_015 = -1 * ts_sum(rank(correlation(rank(high), rank(volume), 5)), 5)。
|
||
|
||
完全相关输入(high 和 volume 同方向)→ correlation = 1.0;
|
||
但 ts_sum 5 期窗口内 rank(1.0) 因 tie-breaking 在 [0, 1] 之间分布,
|
||
所以最后 5 期总和约为 -3.6875(实测值)。
|
||
"""
|
||
high = pd.Series(range(20), dtype=float)
|
||
volume = pd.Series(range(20), dtype=float)
|
||
result = alpha_015(high, volume)
|
||
# 取最后一个完全有效值(rolling warm-up 后)
|
||
valid_last = result.dropna().iloc[-1]
|
||
# 实测:-3.6875(具体数值取决于 pandas rank tie-breaking)
|
||
assert valid_last < 0 # 公式有 -1 系数
|
||
assert valid_last > -5.5 # 不会低于完全负相关
|
||
assert valid_last == pytest.approx(-3.6875, abs=1e-4)
|
||
|
||
|
||
def test_get_alpha_meta_alpha_006_to_015():
|
||
"""alpha_006 到 alpha_015 全部在 registry 中。"""
|
||
for i in range(6, 16):
|
||
alpha_id = f"alpha_{i:03d}"
|
||
meta = get_alpha_meta(alpha_id)
|
||
assert "formula" in meta
|
||
assert "inputs" in meta
|
||
assert len(meta["inputs"]) >= 1
|
||
|
||
|
||
# ── v1.2.0 第三批基础算子(stddev / covariance / log / abs / sign / max_pair / min_pair / indneutralize) ──
|
||
|
||
|
||
def test_stddev_alias():
|
||
"""stddev 与 ts_std 同义。"""
|
||
s = pd.Series(range(20), dtype=float)
|
||
assert stddev(s, 5).equals(ts_std(s, 5))
|
||
|
||
|
||
def test_covariance_perfect():
|
||
"""完全正相关 → 协方差 ≈ var(x)。"""
|
||
s1 = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
s2 = s1 * 2 + 1
|
||
cov = covariance(s1, s2, 5).iloc[-1]
|
||
var = s1.var()
|
||
# cov = corr * std1 * std2 = 1 * std1 * (2*std1) = 2*var
|
||
assert cov == pytest.approx(2 * var)
|
||
|
||
|
||
def test_log_basic():
|
||
"""log(e) ≈ 1。"""
|
||
s = pd.Series([1.0, np.e, np.e**2])
|
||
assert log(s).iloc[0] == pytest.approx(0.0)
|
||
assert log(s).iloc[1] == pytest.approx(1.0)
|
||
assert log(s).iloc[2] == pytest.approx(2.0)
|
||
|
||
|
||
def test_log_negative_input():
|
||
"""log(<=0) → NaN。"""
|
||
s = pd.Series([-1.0, 0.0, 1.0])
|
||
assert pd.isna(log(s).iloc[0])
|
||
assert pd.isna(log(s).iloc[1])
|
||
assert log(s).iloc[2] == pytest.approx(0.0)
|
||
|
||
|
||
def test_abs_basic():
|
||
"""abs_series: |−3| = 3。"""
|
||
from quant_engine.alpha_factors import abs_series
|
||
|
||
assert abs_series(pd.Series([-3.0, 0.0, 5.0])).tolist() == [3.0, 0.0, 5.0]
|
||
|
||
|
||
def test_sign_basic():
|
||
"""sign 函数。"""
|
||
assert sign(pd.Series([-5.0, 0.0, 5.0])).tolist() == [-1.0, 0.0, 1.0]
|
||
|
||
|
||
def test_max_pair():
|
||
"""逐元素 max。"""
|
||
s1 = pd.Series([1.0, 5.0, 3.0])
|
||
s2 = pd.Series([4.0, 2.0, 6.0])
|
||
assert max_pair(s1, s2).tolist() == [4.0, 5.0, 6.0]
|
||
|
||
|
||
def test_min_pair():
|
||
"""逐元素 min。"""
|
||
s1 = pd.Series([1.0, 5.0, 3.0])
|
||
s2 = pd.Series([4.0, 2.0, 6.0])
|
||
assert min_pair(s1, s2).tolist() == [1.0, 2.0, 3.0]
|
||
|
||
|
||
def test_indneutralize():
|
||
"""组内中性化:减去组均值。"""
|
||
s = pd.Series([1.0, 2.0, 3.0, 4.0], index=["a", "a", "b", "b"])
|
||
groups = pd.Series(["g1", "g1", "g2", "g2"], index=s.index)
|
||
result = indneutralize(s, groups)
|
||
# g1 均值 = 1.5;g2 均值 = 3.5
|
||
assert result.iloc[0] == pytest.approx(-0.5)
|
||
assert result.iloc[1] == pytest.approx(0.5)
|
||
assert result.iloc[2] == pytest.approx(-0.5)
|
||
assert result.iloc[3] == pytest.approx(0.5)
|
||
|
||
|
||
# ── v1.2.0 第三批 alpha 公式(alpha_016 – alpha_035) ─────
|
||
|
||
|
||
def test_alpha_016_smoke():
|
||
"""alpha_016 = rank(decay_linear(correlation(rank(high), rank(volume), 5), 5))。"""
|
||
high = pd.Series(np.random.rand(30).cumsum())
|
||
volume = pd.Series(np.random.rand(30) * 100 + 1.0)
|
||
result = alpha_016(high, volume)
|
||
assert isinstance(result, pd.Series)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_017_smoke():
|
||
"""alpha_017 smoke test。"""
|
||
close = pd.Series(np.random.rand(30).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(30) * 100 + 1.0)
|
||
result = alpha_017(close, volume)
|
||
assert isinstance(result, pd.Series)
|
||
|
||
|
||
def test_alpha_018_smoke():
|
||
"""alpha_018 smoke test。"""
|
||
open_ = pd.Series(np.random.rand(30).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(30) * 100 + 1.0)
|
||
result = alpha_018(open_, volume)
|
||
assert isinstance(result, pd.Series)
|
||
|
||
|
||
def test_alpha_019_smoke():
|
||
"""alpha_019 smoke test。"""
|
||
close = pd.Series(np.random.rand(30).cumsum() + 10)
|
||
open_ = pd.Series(np.random.rand(30).cumsum() + 10)
|
||
result = alpha_019(close, open_)
|
||
assert isinstance(result, pd.Series)
|
||
|
||
|
||
def test_alpha_020_smoke():
|
||
"""alpha_020 smoke test。"""
|
||
open_ = pd.Series(np.random.rand(30).cumsum() + 10)
|
||
close = pd.Series(np.random.rand(30).cumsum() + 10)
|
||
result = alpha_020(open_, close)
|
||
assert isinstance(result, pd.Series)
|
||
|
||
|
||
def test_alpha_021_volume_ratio():
|
||
"""alpha_021 = ts_mean(volume, 20) / ts_mean(volume, 60)。"""
|
||
volume = pd.Series([1.0] * 60 + [3.0] * 20)
|
||
result = alpha_021(volume).dropna()
|
||
# 后 20 个 volume=3, 前 40 个=1, ts_mean 20 = 3, ts_mean 60 = (40+60)/60 = 100/60
|
||
expected = 3.0 / (100.0 / 60.0)
|
||
assert result.iloc[-1] == pytest.approx(expected)
|
||
|
||
|
||
def test_alpha_021_zero_long_avg():
|
||
"""alpha_021 分母为 0 → NaN(safe 处理)。"""
|
||
volume = pd.Series([0.0] * 80)
|
||
result = alpha_021(volume)
|
||
assert result.dropna().empty
|
||
|
||
|
||
def test_alpha_022_smoke():
|
||
"""alpha_022 smoke test。"""
|
||
high = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(50) * 100 + 1.0)
|
||
close = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
result = alpha_022(high, volume, close)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_023_smoke():
|
||
"""alpha_023 = -1 * ts_mean(delta(close, 5), 20) * delta(close, 5) / close。"""
|
||
close = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
result = alpha_023(close)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_023_zero_close():
|
||
"""close 中含 0 → 跳过(safe_close)。"""
|
||
close = pd.Series([10.0, 11.0, 0.0, 12.0, 13.0] * 10)
|
||
result = alpha_023(close)
|
||
# 不抛异常;NaN 在 0 处
|
||
assert not result.isna().all()
|
||
|
||
|
||
def test_alpha_024_smoke():
|
||
"""alpha_024 smoke test。"""
|
||
close = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(50) * 100 + 1.0)
|
||
result = alpha_024(close, volume)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_025_smoke():
|
||
"""alpha_025 = rank(decay_linear(correlation(vwap, volume, 4), 8))。"""
|
||
vwap = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(50) * 100 + 1.0)
|
||
result = alpha_025(vwap, volume)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_026_smoke():
|
||
"""alpha_026 smoke test。"""
|
||
open_ = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(50) * 100 + 1.0)
|
||
close = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
result = alpha_026(open_, volume, close)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_027_smoke():
|
||
"""alpha_027 smoke test。"""
|
||
close = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(50) * 100 + 1.0)
|
||
result = alpha_027(close, volume)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_028_scale():
|
||
"""alpha_028 = scale(...) 应满足 |sum| ≈ 1。"""
|
||
close = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
open_ = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
result = alpha_028(close, open_)
|
||
valid = result.dropna()
|
||
assert valid.abs().sum() == pytest.approx(1.0, abs=1e-6)
|
||
|
||
|
||
def test_alpha_029_smoke():
|
||
"""alpha_029 嵌套组合 smoke。"""
|
||
close = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
result = alpha_029(close)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_030_smoke():
|
||
"""alpha_030 = -1 * rank(decay_linear(correlation(rank(low), rank(volume), 5), 5))。"""
|
||
low = pd.Series(np.random.rand(50).cumsum() + 5)
|
||
volume = pd.Series(np.random.rand(50) * 100 + 1.0)
|
||
result = alpha_030(low, volume)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_031_smoke():
|
||
"""alpha_031 smoke test。"""
|
||
close = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(50) * 100 + 1.0)
|
||
result = alpha_031(close, volume)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_032_scale():
|
||
"""alpha_032 = scale(...) → |sum| ≈ 1。"""
|
||
close = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(50) * 100 + 1.0)
|
||
result = alpha_032(close, volume)
|
||
valid = result.dropna()
|
||
assert valid.abs().sum() == pytest.approx(1.0, abs=1e-6)
|
||
|
||
|
||
def test_alpha_033_scale():
|
||
"""alpha_033 = scale(...) → |sum| ≈ 1。"""
|
||
close = pd.Series(np.random.rand(50).cumsum() + 10)
|
||
result = alpha_033(close)
|
||
valid = result.dropna()
|
||
assert valid.abs().sum() == pytest.approx(1.0, abs=1e-6)
|
||
|
||
|
||
def test_alpha_034_volume_ratio():
|
||
"""alpha_034 = ts_mean(volume, 12) / ts_mean(volume, 26)。"""
|
||
volume = pd.Series([1.0] * 50)
|
||
result = alpha_034(volume).dropna()
|
||
# 全部 = 1,ratio = 1.0
|
||
assert result.iloc[-1] == pytest.approx(1.0)
|
||
|
||
|
||
def test_alpha_035_volume_ratio():
|
||
"""alpha_035 = ts_mean(volume, 6) / ts_mean(volume, 12)。"""
|
||
close = pd.Series([1.0] * 30)
|
||
volume = pd.Series([1.0] * 30)
|
||
result = alpha_035(close, volume).dropna()
|
||
assert result.iloc[-1] == pytest.approx(1.0)
|
||
|
||
|
||
# ── v1.2.0 第四批 alpha 公式(alpha_036 – alpha_060) ─────
|
||
|
||
# 共 25 个 smoke test(大多数是组合公式,行为正确即可)
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"alpha_fn,args_fn",
|
||
[
|
||
("alpha_036", lambda c, v, o: (o, c)),
|
||
("alpha_037", lambda c, v, o: (o, c)),
|
||
("alpha_038", lambda c, v, o: (c,)),
|
||
("alpha_039", lambda c, v, o: (v,)),
|
||
("alpha_040", lambda c, v, o: (o * 1.1, c * 0.9, v)),
|
||
("alpha_041", lambda c, v, o: (o * 1.1, c * 0.9)),
|
||
("alpha_042", lambda c, v, o: (c, o * 1.1, c * 0.9)),
|
||
("alpha_043", lambda c, v, o: (c, v)),
|
||
("alpha_044", lambda c, v, o: (o, v)),
|
||
("alpha_045", lambda c, v, o: (c, v)),
|
||
("alpha_046", lambda c, v, o: (c,)),
|
||
("alpha_047", lambda c, v, o: (v, c)),
|
||
("alpha_048", lambda c, v, o: (c,)),
|
||
("alpha_049", lambda c, v, o: (c, v)),
|
||
("alpha_050", lambda c, v, o: (c, v)),
|
||
("alpha_051", lambda c, v, o: (o * 1.1, c * 0.9)),
|
||
("alpha_052", lambda c, v, o: (c,)),
|
||
("alpha_053", lambda c, v, o: (c, o * 1.1, c * 0.9)),
|
||
("alpha_054", lambda c, v, o: (o, c, c * 0.9)),
|
||
("alpha_055", lambda c, v, o: (o, o * 1.1, c * 0.9, v, c)),
|
||
("alpha_056", lambda c, v, o: (c, v)),
|
||
("alpha_057", lambda c, v, o: (c, v)),
|
||
("alpha_058", lambda c, v, o: (v, c)),
|
||
("alpha_059", lambda c, v, o: (c, v)),
|
||
("alpha_060", lambda c, v, o: (c, v)),
|
||
],
|
||
)
|
||
def test_alpha_036_to_060_smoke(alpha_fn, args_fn):
|
||
"""alpha_036 到 alpha_060 全部能跑通(smoke test)。"""
|
||
import sys
|
||
|
||
module = sys.modules["quant_engine.alpha_factors"]
|
||
fn = getattr(module, alpha_fn)
|
||
close = pd.Series(np.random.rand(60).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(60) * 100 + 1.0)
|
||
open_ = close.shift(1).fillna(close.iloc[0])
|
||
args = args_fn(close, volume, open_)
|
||
result = fn(*args)
|
||
assert isinstance(result, pd.Series)
|
||
# 大多数公式应该至少有少量有效值
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
# ── v1.2.0 第五批 alpha 公式(alpha_061 – alpha_085) ─────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"alpha_fn,args_fn",
|
||
[
|
||
("alpha_061", lambda c, v, o, h, lo: (h, lo, v)),
|
||
("alpha_062", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_063", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_064", lambda c, v, o, h, lo: (o, c, v)),
|
||
("alpha_065", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_066", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_067", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_068", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_069", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_070", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_071", lambda c, v, o, h, lo: (o, c, lo, v)),
|
||
("alpha_072", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_073", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_074", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_075", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_076", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_077", lambda c, v, o, h, lo: (h, lo, v)),
|
||
("alpha_078", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_079", lambda c, v, o, h, lo: (c, h, lo)),
|
||
("alpha_080", lambda c, v, o, h, lo: (o, c, v)),
|
||
("alpha_081", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_082", lambda c, v, o, h, lo: (o, v)),
|
||
("alpha_083", lambda c, v, o, h, lo: (h, lo, v)),
|
||
("alpha_084", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_085", lambda c, v, o, h, lo: (c, v)),
|
||
],
|
||
)
|
||
def test_alpha_061_to_085_smoke(alpha_fn, args_fn):
|
||
"""alpha_061 到 alpha_085 smoke test。"""
|
||
import sys
|
||
|
||
module = sys.modules["quant_engine.alpha_factors"]
|
||
fn = getattr(module, alpha_fn)
|
||
close = pd.Series(np.random.rand(60).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(60) * 100 + 1.0)
|
||
high = close * 1.05
|
||
low = close * 0.95
|
||
open_ = close.shift(1).fillna(close.iloc[0])
|
||
args = args_fn(close, volume, open_, high, low)
|
||
result = fn(*args)
|
||
assert isinstance(result, pd.Series)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_get_alpha_meta_alpha_061_to_085():
|
||
"""alpha_061 到 alpha_085 全部在 registry 中。"""
|
||
for i in range(61, 86):
|
||
alpha_id = f"alpha_{i:03d}"
|
||
meta = get_alpha_meta(alpha_id)
|
||
assert "formula" in meta
|
||
assert "inputs" in meta
|
||
assert len(meta["inputs"]) >= 1
|
||
|
||
|
||
# ── v1.2.0 第六批 alpha 公式(alpha_086 – alpha_110) ─────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"alpha_fn,args_fn",
|
||
[
|
||
("alpha_086", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_087", lambda c, v, o, h, lo: (o, c, v)),
|
||
("alpha_088", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_089", lambda c, v, o, h, lo: (h, lo, v)),
|
||
("alpha_090", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_091", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_092", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_093", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_094", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_095", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_096", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_097", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_098", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_099", lambda c, v, o, h, lo: (h, lo, v)),
|
||
("alpha_100", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_101", lambda c, v, o, h, lo: (c, h, lo)),
|
||
("alpha_102", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_103", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_104", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_105", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_106", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_107", lambda c, v, o, h, lo: (c, v)),
|
||
("alpha_108", lambda c, v, o, h, lo: (h, lo, v)),
|
||
("alpha_109", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
("alpha_110", lambda c, v, o, h, lo: (c, h, lo, v)),
|
||
],
|
||
)
|
||
def test_alpha_086_to_110_smoke(alpha_fn, args_fn):
|
||
"""alpha_086 到 alpha_110 smoke test。"""
|
||
import sys
|
||
|
||
module = sys.modules["quant_engine.alpha_factors"]
|
||
fn = getattr(module, alpha_fn)
|
||
close = pd.Series(np.random.rand(60).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(60) * 100 + 1.0)
|
||
high = close * 1.05
|
||
low = close * 0.95
|
||
open_ = close.shift(1).fillna(close.iloc[0])
|
||
args = args_fn(close, volume, open_, high, low)
|
||
result = fn(*args)
|
||
assert isinstance(result, pd.Series)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
# ── v1.2.0 第七批 alpha 公式(alpha_111 – alpha_135) ─────
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"alpha_id,args_fn",
|
||
[
|
||
(
|
||
f"alpha_{i:03d}",
|
||
lambda c, v, o, h, lo, _i=i: (
|
||
(c, v) if _i % 3 == 0 else (c, v) if _i % 3 == 1 else (h, lo, v)
|
||
),
|
||
)
|
||
for i in range(111, 136)
|
||
],
|
||
)
|
||
def test_alpha_111_to_135_smoke(alpha_id, args_fn):
|
||
"""alpha_111 到 alpha_135 smoke test(模板化 25 个)。"""
|
||
import sys
|
||
|
||
fn = getattr(sys.modules["quant_engine.alpha_factors"], alpha_id)
|
||
close = pd.Series(np.random.rand(60).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(60) * 100 + 1.0)
|
||
high = close * 1.05
|
||
low = close * 0.95
|
||
open_ = close.shift(1).fillna(close.iloc[0])
|
||
args = args_fn(close, volume, open_, high, low)
|
||
result = fn(*args)
|
||
assert isinstance(result, pd.Series)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
# ── v1.2.0 第八批 alpha 公式(alpha_136 – alpha_158) ─────
|
||
# 这是 alpha158 完整版的最后一批——达成 158 个公式
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"alpha_id",
|
||
[f"alpha_{i:03d}" for i in range(136, 159)],
|
||
)
|
||
def test_alpha_136_to_158_smoke(alpha_id):
|
||
"""alpha_136 到 alpha_158 smoke test(达成 158 完整版)。"""
|
||
import sys
|
||
|
||
fn = getattr(sys.modules["quant_engine.alpha_factors"], alpha_id)
|
||
close = pd.Series(np.random.rand(60).cumsum() + 10)
|
||
volume = pd.Series(np.random.rand(60) * 100 + 1.0)
|
||
high = close * 1.05
|
||
low = close * 0.95
|
||
args = fn.__code__.co_varnames[: fn.__code__.co_argcount]
|
||
args_series = {
|
||
"close": close,
|
||
"volume": volume,
|
||
"high": high,
|
||
"low": low,
|
||
"open": close.shift(1).fillna(close.iloc[0]),
|
||
}
|
||
fn_args = tuple(args_series[a] for a in args if a in args_series)
|
||
result = fn(*fn_args)
|
||
assert isinstance(result, pd.Series)
|
||
assert result.dropna().shape[0] > 0
|
||
|
||
|
||
def test_alpha_158_complete():
|
||
"""确认 alpha158 完整版:158 个算子全部可用 + 全部元数据。"""
|
||
assert len(ALPHA158_REGISTRY) == 158
|
||
for i in range(1, 159):
|
||
alpha_id = f"alpha_{i:03d}"
|
||
meta = get_alpha_meta(alpha_id)
|
||
assert "formula" in meta
|
||
assert "inputs" in meta
|
||
assert len(meta["inputs"]) >= 1
|
||
|
||
|
||
# ── v1.2.0 Phase 2: dump/load / JSONB 兼容工具 ─────
|
||
|
||
|
||
def test_dump_alpha_registry_jsonl(tmp_path):
|
||
"""dump_alpha_registry_jsonl 应产生 JSONL 文件。"""
|
||
from quant_engine.alpha_factors import dump_alpha_registry_jsonl, load_alpha_registry_jsonl
|
||
|
||
output_path = tmp_path / "alpha_registry.jsonl"
|
||
count = dump_alpha_registry_jsonl(str(output_path))
|
||
assert count == 158
|
||
assert output_path.exists()
|
||
# 加载回来验证
|
||
loaded = load_alpha_registry_jsonl(str(output_path))
|
||
assert len(loaded) == 158
|
||
assert "alpha_001" in loaded
|
||
assert "alpha_158" in loaded
|
||
|
||
|
||
def test_dump_load_roundtrip(tmp_path):
|
||
"""dump + load 应保持数据完整性。"""
|
||
from quant_engine.alpha_factors import (
|
||
ALPHA158_REGISTRY,
|
||
dump_alpha_registry_jsonl,
|
||
load_alpha_registry_jsonl,
|
||
)
|
||
|
||
output_path = tmp_path / "roundtrip.jsonl"
|
||
dump_alpha_registry_jsonl(str(output_path))
|
||
loaded = load_alpha_registry_jsonl(str(output_path))
|
||
# 抽样验证
|
||
for alpha_id in ["alpha_001", "alpha_050", "alpha_158"]:
|
||
assert alpha_id in loaded
|
||
original = ALPHA158_REGISTRY[alpha_id]
|
||
# 注意:load 后 id 字段已剥离,其余字段应完全一致
|
||
for key in original:
|
||
assert loaded[alpha_id].get(key) == original[key], f"Mismatch in {alpha_id}.{key}"
|
||
|
||
|
||
def test_alpha_registry_to_pg_rows():
|
||
"""alpha_registry_to_pg_rows 应输出 PG INSERT 行结构。"""
|
||
from quant_engine.alpha_factors import alpha_registry_to_pg_rows
|
||
|
||
rows = alpha_registry_to_pg_rows()
|
||
assert len(rows) == 158
|
||
# 验证第一行结构
|
||
row = rows[0]
|
||
assert "name" in row
|
||
assert "formula" in row
|
||
assert "metadata" in row
|
||
assert "version" in row
|
||
assert row["version"] == "1.2.0"
|
||
assert isinstance(row["metadata"], dict)
|
||
assert "category" in row["metadata"]
|
||
|
||
|
||
def test_parse_alpha_formula_basic():
|
||
"""parse_alpha_formula 应提取函数名与变量。"""
|
||
from quant_engine.alpha_factors import parse_alpha_formula
|
||
|
||
parsed = parse_alpha_formula("rank(ts_rank(close, 5))")
|
||
assert "raw" in parsed
|
||
assert "operators" in parsed
|
||
assert "functions" in parsed
|
||
assert "variables" in parsed
|
||
assert "close" in parsed["variables"]
|
||
assert "rank" in parsed["operators"]
|
||
|
||
|
||
def test_parse_alpha_formula_complex():
|
||
"""复杂公式解析应识别多函数 + 多变量。"""
|
||
from quant_engine.alpha_factors import parse_alpha_formula
|
||
|
||
parsed = parse_alpha_formula("-1 * rank(decay_linear(correlation(close, volume, 10), 5))")
|
||
# 应识别 correlation, decay_linear 函数调用
|
||
func_names = [f["name"] for f in parsed["functions"]]
|
||
assert "correlation" in func_names
|
||
assert "decay_linear" in func_names
|
||
# 应识别 close, volume 变量
|
||
assert "close" in parsed["variables"]
|
||
assert "volume" in parsed["variables"]
|
||
|
||
|
||
def test_parse_alpha_formula_round_trip_jsonb():
|
||
"""parse_alpha_formula 输出可 json.dumps → JSONB 落库。"""
|
||
import json
|
||
from quant_engine.alpha_factors import parse_alpha_formula
|
||
|
||
parsed = parse_alpha_formula("rank(decay_linear(rank(ts_rank(close, 5)), 5))")
|
||
# 应能 JSON 序列化
|
||
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)
|
||
|
||
|
||
# ── 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)
|
||
|
||
|
||
# ── Alpha158 Phase 3: versioned alpha001-alpha050 formula contract ──────────
|
||
|
||
|
||
def _phase3_market_inputs() -> dict[str, pd.Series]:
|
||
positions = np.arange(80, dtype=float)
|
||
index = pd.RangeIndex(len(positions), name="row")
|
||
open_ = pd.Series(100.0 + positions * 0.2 + np.sin(positions / 4.0), index=index)
|
||
close = pd.Series(100.5 + positions * 0.18 + np.cos(positions / 5.0), index=index)
|
||
high = pd.Series(np.maximum(open_, close) + 1.0, index=index)
|
||
low = pd.Series(np.minimum(open_, close) - 1.0, index=index)
|
||
volume = pd.Series(1_000.0 + positions**1.3 + 20.0 * np.sin(positions / 3.0), index=index)
|
||
vwap = (open_ + close + high + low) / 4.0
|
||
return {
|
||
"open": open_,
|
||
"close": close,
|
||
"high": high,
|
||
"low": low,
|
||
"volume": volume,
|
||
"vwap": vwap,
|
||
}
|
||
|
||
|
||
def test_phase3_formula_catalog_is_versioned_exact_and_content_addressed():
|
||
import hashlib
|
||
import json
|
||
from collections import Counter
|
||
|
||
expected_ids = tuple(f"alpha_{number:03d}" for number in range(1, 51))
|
||
expected_fields = {
|
||
"name",
|
||
"contract_version",
|
||
"formula",
|
||
"category",
|
||
"complexity",
|
||
"parameters",
|
||
"description",
|
||
"references",
|
||
"call_inputs",
|
||
"formula_inputs",
|
||
"input_category",
|
||
}
|
||
|
||
assert ALPHA158_PHASE3_FORMULA_CONTRACT_VERSION == "1.0.0"
|
||
assert list_phase3_formulas() == expected_ids
|
||
assert tuple(ALPHA158_PHASE3_FORMULA_SPECS) == expected_ids
|
||
assert Counter(
|
||
spec["input_category"] for spec in ALPHA158_PHASE3_FORMULA_SPECS.values()
|
||
) == {"single": 19, "pair": 26, "triple": 4, "quadruple": 1}
|
||
|
||
for alpha_id, spec in ALPHA158_PHASE3_FORMULA_SPECS.items():
|
||
assert set(spec) == expected_fields
|
||
assert spec["name"] == alpha_id
|
||
assert spec["contract_version"] == ALPHA158_PHASE3_FORMULA_CONTRACT_VERSION
|
||
assert spec["formula"] == ALPHA158_REGISTRY[alpha_id]["formula"]
|
||
|
||
serializable_specs = {
|
||
alpha_id: {
|
||
field: list(value) if isinstance(value, tuple) else value
|
||
for field, value in spec.items()
|
||
}
|
||
for alpha_id, spec in ALPHA158_PHASE3_FORMULA_SPECS.items()
|
||
}
|
||
encoded = json.dumps(
|
||
serializable_specs,
|
||
sort_keys=True,
|
||
separators=(",", ":"),
|
||
ensure_ascii=False,
|
||
).encode()
|
||
assert hashlib.sha256(encoded).hexdigest() == ALPHA158_PHASE3_FORMULA_CATALOG_SHA256
|
||
assert ALPHA158_PHASE3_FORMULA_CATALOG_SHA256 == (
|
||
"9a3360e5ee77a85a35d3c2fdab1efaa531fa0c263a2cb2bb5b965a1fb96fe1bd"
|
||
)
|
||
|
||
|
||
def test_phase3_formula_catalog_is_recursively_immutable():
|
||
import operator
|
||
|
||
with pytest.raises(TypeError):
|
||
operator.setitem(ALPHA158_PHASE3_FORMULA_SPECS, "alpha_001", {})
|
||
with pytest.raises(TypeError):
|
||
operator.setitem(
|
||
ALPHA158_PHASE3_FORMULA_SPECS["alpha_001"],
|
||
"formula",
|
||
"changed",
|
||
)
|
||
with pytest.raises(TypeError):
|
||
operator.setitem(
|
||
ALPHA158_PHASE3_FORMULA_SPECS["alpha_001"]["call_inputs"],
|
||
0,
|
||
"volume",
|
||
)
|
||
|
||
|
||
def test_phase3_catalog_freezes_callable_signatures_without_rewriting_formulas():
|
||
import inspect
|
||
|
||
legacy_formula_input_differences = {
|
||
"alpha_011": ("close", "high", "low"),
|
||
"alpha_035": ("volume",),
|
||
"alpha_036": ("close",),
|
||
"alpha_040": ("high", "low"),
|
||
"alpha_042": ("close",),
|
||
"alpha_043": ("volume",),
|
||
}
|
||
|
||
for alpha_id, spec in ALPHA158_PHASE3_FORMULA_SPECS.items():
|
||
function = getattr(alpha_factors_module, alpha_id)
|
||
signature_inputs = tuple(
|
||
"open" if name == "open_" else name
|
||
for name in inspect.signature(function).parameters
|
||
)
|
||
assert spec["call_inputs"] == signature_inputs
|
||
assert spec["formula_inputs"] == legacy_formula_input_differences.get(
|
||
alpha_id,
|
||
signature_inputs,
|
||
)
|
||
|
||
assert ALPHA158_PHASE3_FORMULA_SPECS["alpha_035"]["call_inputs"] == (
|
||
"close",
|
||
"volume",
|
||
)
|
||
assert ALPHA158_REGISTRY["alpha_035"]["inputs"] == ["volume"]
|
||
|
||
|
||
def test_phase3_dispatch_matches_all_existing_alpha001_alpha050_functions():
|
||
inputs = _phase3_market_inputs()
|
||
|
||
for alpha_id, spec in ALPHA158_PHASE3_FORMULA_SPECS.items():
|
||
call_inputs = spec["call_inputs"]
|
||
function = getattr(alpha_factors_module, alpha_id)
|
||
expected = function(*(inputs[name] for name in call_inputs))
|
||
actual = evaluate_phase3_formula(
|
||
alpha_id,
|
||
**{name: inputs[name] for name in reversed(call_inputs)},
|
||
)
|
||
pd.testing.assert_series_equal(actual, expected)
|
||
|
||
|
||
def test_phase3_dispatch_rejects_unknown_missing_extra_and_non_series_inputs():
|
||
inputs = _phase3_market_inputs()
|
||
|
||
with pytest.raises(KeyError, match="not registered"):
|
||
evaluate_phase3_formula("alpha_051", close=inputs["close"])
|
||
with pytest.raises(ValueError, match=r"missing inputs.*volume"):
|
||
evaluate_phase3_formula("alpha_005", close=inputs["close"])
|
||
with pytest.raises(ValueError, match=r"unexpected inputs.*vwap"):
|
||
evaluate_phase3_formula(
|
||
"alpha_005",
|
||
close=inputs["close"],
|
||
volume=inputs["volume"],
|
||
vwap=inputs["vwap"],
|
||
)
|
||
with pytest.raises(TypeError, match="close must be a pandas Series"):
|
||
evaluate_phase3_formula( # type: ignore[arg-type]
|
||
"alpha_005",
|
||
close=[1.0, 2.0],
|
||
volume=inputs["volume"],
|
||
)
|
||
|
||
|
||
def test_phase3_dispatch_rejects_implicit_series_alignment():
|
||
inputs = _phase3_market_inputs()
|
||
misaligned_volume = inputs["volume"].rename(index={79: 80})
|
||
|
||
with pytest.raises(ValueError, match="volume index must align with close"):
|
||
evaluate_phase3_formula(
|
||
"alpha_005",
|
||
close=inputs["close"],
|
||
volume=misaligned_volume,
|
||
)
|