feat(b2.2): low-vol factor + price-only composite (momentum+lowvol); keep value/quality for future
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user