Files
fxhnt/tests/integration/test_equity_factor.py
2026-06-16 22:27:26 +02:00

73 lines
3.0 KiB
Python

"""Pure equity-factor math: winsorized z-score, family/composite scores, 3 construction weightings, book return."""
from __future__ import annotations
import pytest
from fxhnt.domain.strategies import equity_factor as ef
def test_robust_z_winsorizes_and_standardizes() -> None:
z = ef.robust_z([1.0, 2.0, 3.0, 4.0, 100.0], k=2.0) # 100 is an outlier
assert max(z) == pytest.approx(2.0) # winsorized at +k
assert all(-2.0 - 1e-9 <= v <= 2.0 + 1e-9 for v in z)
def test_robust_z_missing_is_neutral_zero() -> None:
z = ef.robust_z([1.0, None, 3.0])
assert z[1] == 0.0 # missing -> neutral
def test_composite_is_equal_weight_of_families() -> None:
c = ef.composite(value_z=[1.0, -1.0], quality_z=[1.0, -1.0], momentum_z=[1.0, -1.0])
assert c == pytest.approx([1.0, -1.0])
def test_lowvol_score_low_vol_ranks_high() -> None:
# low-volatility anomaly: lowest trailing vol -> highest score, highest vol -> lowest
z = ef.lowvol_score([0.10, 0.20, 0.30, 0.40, 0.50, None])
assert z[0] == max(z[:5]) # lowest vol -> highest score
assert z[4] == min(z[:5]) # highest vol -> lowest score
assert z[5] == 0.0 # None -> neutral
def test_composite_price_equal_weight() -> None:
c = ef.composite_price(momentum_z=[1.0, -1.0], lowvol_z=[1.0, -1.0])
assert c == pytest.approx([1.0, -1.0])
# momentum and lowvol disagree -> equal-weight mean
c2 = ef.composite_price(momentum_z=[2.0, 0.0], lowvol_z=[0.0, 2.0])
assert c2 == pytest.approx([1.0, 1.0])
def test_long_only_weights_top_quintile_sum_to_one() -> None:
scores = [float(i) for i in range(10)] # 0..9; top quintile = top 2 (8,9)
w = ef.construction_weights(scores, "long", quantile=0.2)
assert sum(w) == pytest.approx(1.0)
assert w[9] == pytest.approx(0.5) and w[8] == pytest.approx(0.5)
assert all(w[i] == 0.0 for i in range(8))
def test_long_short_is_market_neutral_gross_two() -> None:
scores = [float(i) for i in range(10)]
w = ef.construction_weights(scores, "ls", quantile=0.2)
assert sum(w) == pytest.approx(0.0)
assert sum(abs(x) for x in w) == pytest.approx(2.0)
assert w[9] == pytest.approx(0.5) and w[0] == pytest.approx(-0.5)
def test_tilt_is_long_only_rank_weighted_sum_one() -> None:
scores = [float(i) for i in range(10)]
w = ef.construction_weights(scores, "tilt")
assert sum(w) == pytest.approx(1.0)
assert all(x >= 0.0 for x in w)
assert w[9] > w[5] >= 0.0
def test_book_return_weighted_minus_short_borrow() -> None:
prev_w = {"A": 0.5, "B": -0.5}
prev_p = {"A": 100.0, "B": 100.0}
cur_p = {"A": 110.0, "B": 90.0}
r = ef.book_return(prev_w, prev_p, cur_p, borrow_daily=0.0)
assert r == pytest.approx(0.5 * 0.10 + (-0.5) * (-0.10)) # +0.10
r2 = ef.book_return(prev_w, prev_p, cur_p, borrow_daily=0.001)
assert r2 == pytest.approx(r - 0.001 * 0.5)