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:
jgrusewski
2026-06-16 22:27:26 +02:00
parent 9835d64cbb
commit d29593e0ed
2 changed files with 38 additions and 2 deletions

View File

@@ -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)

View File

@@ -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)