diff --git a/src/fxhnt/domain/strategies/equity_factor.py b/src/fxhnt/domain/strategies/equity_factor.py index 48339b5..b4b2f6a 100644 --- a/src/fxhnt/domain/strategies/equity_factor.py +++ b/src/fxhnt/domain/strategies/equity_factor.py @@ -1,5 +1,13 @@ -"""Pure cross-sectional factor math: winsorized z-scores, value/quality/momentum families, composite, -and the three portfolio constructions (long / long-short / tilt) + realized book return. No I/O.""" +"""Pure cross-sectional factor math: winsorized z-scores, price/fundamentals factor families, +composites, and the three portfolio constructions (long / long-short / tilt) + realized book return. +No I/O. + +The ACTIVE sleeve is PRICE-ONLY (momentum + low-vol) via `composite_price`, because prices are +available broadly across the universe (including delisted names). The fundamentals factors +(`value_score`, `quality_score`) and the 3-factor `composite` are RETAINED for a future Tiingo +fundamentals upgrade — the current Tiingo plan exposes fundamentals for DOW-30 only, so they cannot +score a broad universe yet. They are intentionally not used by `composite_price`. +""" from __future__ import annotations import math @@ -52,10 +60,22 @@ def momentum_score(mom_12_1: list[float | None]) -> list[float]: return robust_z(mom_12_1) +def lowvol_score(vols: list[float | None]) -> list[float]: + """Low-volatility anomaly: LOWER trailing realized vol -> HIGHER score (None stays neutral 0.0).""" + return robust_z([None if v is None else -v for v in vols]) + + def composite(value_z: list[float], quality_z: list[float], momentum_z: list[float]) -> list[float]: + """3-factor (value+quality+momentum) composite. RETAINED for a future Tiingo fundamentals + upgrade (fundamentals currently DOW-30-only); NOT used by the active price-only sleeve.""" return _mean_z([value_z, quality_z, momentum_z]) +def composite_price(momentum_z: list[float], lowvol_z: list[float]) -> list[float]: + """ACTIVE price-only composite for the pivoted sleeve: equal-weight mean of momentum + low-vol z.""" + return _mean_z([momentum_z, lowvol_z]) + + def _quintile_cut(scores: list[float], quantile: float) -> tuple[float, float]: s = sorted(scores) n = len(s) diff --git a/tests/integration/test_equity_factor.py b/tests/integration/test_equity_factor.py index e46ee7a..1faa1e9 100644 --- a/tests/integration/test_equity_factor.py +++ b/tests/integration/test_equity_factor.py @@ -22,6 +22,22 @@ def test_composite_is_equal_weight_of_families() -> None: 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)