112 lines
3.8 KiB
Python
112 lines
3.8 KiB
Python
# tests/factor/test_metrics.py
|
|
"""向量化指标:已知输入的精确断言."""
|
|
import sys, os
|
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
|
|
from sanguo_factor.metrics import (
|
|
rank_corr_rows, factor_turnover, long_short_annual_return,
|
|
decile_annual_returns, monthly_ic, classify, summarize_factor,
|
|
TRADING_DAYS_PER_YEAR,
|
|
)
|
|
|
|
|
|
def _mat(values, cols=("A", "B", "C")):
|
|
n_rows = len(values)
|
|
idx = pd.to_datetime([f"2024-01-{i+1:02d}" for i in range(n_rows)])
|
|
return pd.DataFrame(values, index=idx, columns=list(cols), dtype=float)
|
|
|
|
|
|
def test_rank_corr_perfect_monotonic():
|
|
F = _mat([[1, 2, 3], [3, 2, 1], [1, 3, 2]])
|
|
R = _mat([[10, 20, 30], [30, 20, 10], [10, 30, 20]])
|
|
ic = rank_corr_rows(F, R)
|
|
assert (ic == 1.0).all()
|
|
|
|
|
|
def test_rank_corr_inverse():
|
|
F = _mat([[1, 2, 3], [1, 2, 3]])
|
|
R = _mat([[3, 2, 1], [30, 20, 10]])
|
|
ic = rank_corr_rows(F, R)
|
|
assert (ic == -1.0).all()
|
|
|
|
|
|
def test_rank_corr_nan_propagates_row():
|
|
F = _mat([[1, 2, 3], [np.nan, 2, 3]])
|
|
R = _mat([[1, 2, 3], [1, 2, 3]])
|
|
ic = rank_corr_rows(F, R)
|
|
assert not np.isnan(ic.iloc[0])
|
|
assert np.isnan(ic.iloc[1]) # 2个有效值秩恒定 → 无方差 → nan
|
|
|
|
|
|
def test_turnover_zero_for_static():
|
|
F = _mat([[1, 2, 3]] * 4)
|
|
assert factor_turnover(F) == pytest.approx(0.0)
|
|
|
|
|
|
def test_turnover_full_for_shuffled():
|
|
F = _mat([[1, 2, 3], [3, 2, 1]]) # 完全逆序 → 秩相关-1 → 换手=2
|
|
assert factor_turnover(F) == pytest.approx(2.0)
|
|
|
|
|
|
def test_long_short_direction():
|
|
F = _mat([[1, 2, 3, 4, 5]] * 2, cols=tuple("ABCDE"))
|
|
R = _mat([[0.01, 0.02, 0.03, 0.04, 0.05],
|
|
[0.01, 0.02, 0.03, 0.04, 0.05]], cols=tuple("ABCDE"))
|
|
# 每日 top10%(1只)=E bottom10%=A → 日均 0.04
|
|
expected = (1 + 0.04) ** TRADING_DAYS_PER_YEAR - 1
|
|
assert long_short_annual_return(F, R) == pytest.approx(expected)
|
|
|
|
|
|
def test_decile_monotonic():
|
|
F = _mat([list(range(1, 11))] * 3, cols=tuple("ABCDEFGHIJ"))
|
|
R = _mat([[c / 100 for c in range(1, 11)]] * 3, cols=tuple("ABCDEFGHIJ"))
|
|
dec = decile_annual_returns(F, R)
|
|
assert len(dec) == 10
|
|
assert all(d is not None for d in dec)
|
|
assert dec == sorted(dec) # D1最低收益 → D10最高收益 单调
|
|
|
|
|
|
def test_monthly_ic_shape():
|
|
idx = pd.to_datetime(["2024-01-05", "2024-01-10", "2024-02-01"])
|
|
out = monthly_ic(pd.Series([0.1, 0.2, -0.1], index=idx))
|
|
assert out[0] == {"month": "2024-01", "ic": pytest.approx(0.15)}
|
|
assert out[1] == {"month": "2024-02", "ic": pytest.approx(-0.1)}
|
|
|
|
|
|
def test_classify_rules():
|
|
assert classify(0.5, 5.0) == "effective"
|
|
assert classify(-0.4, -3.0) == "effective" # 负 IC 强因子同样有效(反向)
|
|
assert classify(0.1, 1.8) == "watch"
|
|
assert classify(0.0, 0.5) == "eliminated"
|
|
|
|
|
|
def test_summarize_factor_structure():
|
|
rng = np.random.default_rng(7)
|
|
idx = pd.bdate_range("2024-01-01", periods=60)
|
|
base = np.tile(np.arange(10.0, 90.0, 1.0), (60, 1))
|
|
F = pd.DataFrame(base + rng.normal(0, 0.5, base.shape), index=idx)
|
|
R1 = pd.DataFrame(-0.001 * base + rng.normal(0, 0.001, base.shape), index=idx, columns=F.columns)
|
|
R5, R10 = R1 * 5, R1 * 10
|
|
out = summarize_factor(F, R1, R5, R10)
|
|
assert set(out) == {"1", "5", "10", "turnover"}
|
|
p1 = out["1"]
|
|
for key in ("count", "ic_mean", "ic_std", "icir", "t_stat", "win_rate",
|
|
"ls_annual", "monthly_ic", "deciles", "conclusion"):
|
|
assert key in p1
|
|
assert p1["count"] == 60
|
|
assert p1["ic_mean"] < 0 # 构造为负相关
|
|
|
|
|
|
def test_rank_corr_rows_speed_guard():
|
|
import time
|
|
rng = np.random.default_rng(1)
|
|
F = pd.DataFrame(rng.normal(size=(2000, 100)))
|
|
R = pd.DataFrame(rng.normal(size=(2000, 100)))
|
|
t0 = time.time()
|
|
rank_corr_rows(F, R)
|
|
assert time.time() - t0 < 3
|