fix: recalibrate FIP path labels to live equity scale
Replace inert ±0.25 bands with ~p25/p75 cutoffs from the prod snapshot (−0.08 / 0.00). Document zero-return dilution and left-skewed distribution.
This commit is contained in:
@@ -408,6 +408,17 @@ def compute_pivot_points(
|
||||
}
|
||||
|
||||
|
||||
# Path labels for display only. Calibrated on the ~505-name prod snapshot
|
||||
# (2026-07, n=502 with full history): empirical p25 ≈ −0.082, p75 ≈ +0.004,
|
||||
# mean ≈ −0.043. Paper-style |ID| ≳ 0.25 almost never appears in live equities
|
||||
# (only ~0.2% of names); real momentum winners cluster around −0.04…−0.12.
|
||||
# Thresholds are therefore ~quartile cutoffs, not ±0.25 textbook extremes.
|
||||
# Distribution is left-skewed (bullish sample → more "continuous" than "discrete"),
|
||||
# so the discrete band is not symmetric.
|
||||
FIP_PATH_CONTINUOUS_MAX = -0.08 # ~p25: smoother quartile
|
||||
FIP_PATH_DISCRETE_MIN = 0.00 # ~p75: less-continuous quartile
|
||||
|
||||
|
||||
def compute_fip_id(closes: list[float], as_of_index: int | None = None) -> dict[str, Any]:
|
||||
"""Da/Gurun/Warachka information discreteness over the 12-1 formation window.
|
||||
|
||||
@@ -418,8 +429,13 @@ def compute_fip_id(closes: list[float], as_of_index: int | None = None) -> dict[
|
||||
ID = sign(PRET) × (%neg − %pos)
|
||||
|
||||
Lower ID ⇒ smoother / more continuous path (for a winner: many small up days).
|
||||
Higher ID ⇒ jumpy / discrete path (few large moves). Zero-return days count
|
||||
in neither numerator but remain in the denominator.
|
||||
Higher ID ⇒ jumpy / discrete path (few large moves).
|
||||
|
||||
Zero-return days count in neither numerator but remain in the denominator
|
||||
(paper definition). Quirk: a flat series with one big jump can still land
|
||||
near zero ("mixed") because zeros dilute %pos/%neg — faithful to the paper
|
||||
and to real equities (exact zero daily returns are rare). Synthetic jump
|
||||
tests assert ordering vs a steady climber, not the discrete label itself.
|
||||
"""
|
||||
i = len(closes) - 1 if as_of_index is None else as_of_index
|
||||
if i < 252 or closes[i - 252] <= 0 or closes[i - 21] <= 0:
|
||||
@@ -448,12 +464,13 @@ def compute_fip_id(closes: list[float], as_of_index: int | None = None) -> dict[
|
||||
else:
|
||||
sign = 0.0
|
||||
fip = sign * (pct_neg - pct_pos)
|
||||
# Map [-1, 1] → score where lower ID (smoother) is higher for display only.
|
||||
# Map observed ID range (~[-0.3, 0.15]) loosely to 0–100 for the card chrome;
|
||||
# lower ID (smoother) → higher score. Display only.
|
||||
score = max(0.0, min(100.0, 50.0 * (1.0 - fip)))
|
||||
if fip <= -0.25:
|
||||
if fip <= FIP_PATH_CONTINUOUS_MAX:
|
||||
path = "continuous"
|
||||
path_label = "smooth grind (continuous information)"
|
||||
elif fip >= 0.25:
|
||||
elif fip >= FIP_PATH_DISCRETE_MIN:
|
||||
path = "discrete"
|
||||
path_label = "jumpy path (discrete information)"
|
||||
else:
|
||||
|
||||
@@ -192,6 +192,9 @@ function interpretation(
|
||||
|
||||
case 'fip_id': {
|
||||
// Display context only — not a production gate input.
|
||||
// Prefer backend path_label (calibrated ~p25/p75 on live equities).
|
||||
// Fallback thresholds match indicator_service FIP_PATH_* constants
|
||||
// (not ±0.25 — that band almost never fires on real names).
|
||||
const label = typeof v.path_label === 'string' ? v.path_label : null;
|
||||
const path = typeof v.path === 'string' ? v.path : null;
|
||||
if (label) {
|
||||
@@ -201,8 +204,8 @@ function interpretation(
|
||||
}
|
||||
const fip = num('fip_id');
|
||||
if (fip == null) return null;
|
||||
if (fip <= -0.25) return { text: 'smooth grind (continuous)', tone: 'text-emerald-300' };
|
||||
if (fip >= 0.25) return { text: 'jumpy path (discrete)', tone: 'text-amber-300' };
|
||||
if (fip <= -0.08) return { text: 'smooth grind (continuous)', tone: 'text-emerald-300' };
|
||||
if (fip >= 0) return { text: 'jumpy path (discrete)', tone: 'text-amber-300' };
|
||||
return { text: 'mixed path', tone: 'text-gray-300' };
|
||||
}
|
||||
|
||||
|
||||
@@ -280,6 +280,8 @@ class TestComputeFipId:
|
||||
assert 0 <= result["score"] <= 100
|
||||
|
||||
def test_jump_then_flat_is_more_discrete_than_steady(self):
|
||||
# Ordering only: zeros dilute %pos/%neg so a pure jump can land near
|
||||
# zero ("mixed") rather than "discrete" — paper-faithful, not a bug.
|
||||
steady = [100.0 * (1.002 ** i) for i in range(280)]
|
||||
jumpy = [100.0] * 252
|
||||
jumpy.append(100.0 * 1.5)
|
||||
@@ -288,6 +290,25 @@ class TestComputeFipId:
|
||||
id_jumpy = compute_fip_id(jumpy)["fip_id"]
|
||||
assert id_jumpy > id_steady
|
||||
|
||||
def test_label_thresholds_match_live_equity_scale(self):
|
||||
# Calibrated cutoffs: continuous ≤ −0.08, discrete ≥ 0 (not ±0.25).
|
||||
from app.services.indicator_service import (
|
||||
FIP_PATH_CONTINUOUS_MAX,
|
||||
FIP_PATH_DISCRETE_MIN,
|
||||
)
|
||||
|
||||
assert FIP_PATH_CONTINUOUS_MAX == pytest.approx(-0.08)
|
||||
assert FIP_PATH_DISCRETE_MIN == pytest.approx(0.0)
|
||||
# Mild continuous (typical momentum winner scale).
|
||||
mild = [100.0]
|
||||
for i in range(1, 280):
|
||||
# ~54% up days with small steps → fip roughly −0.08…−0.12 region.
|
||||
mild.append(mild[-1] * (1.0015 if i % 2 == 0 or i % 5 != 0 else 0.9995))
|
||||
r = compute_fip_id(mild)
|
||||
assert r["fip_id"] > -0.25 # not textbook extreme
|
||||
# Labels must fire somewhere in the observed equity band.
|
||||
assert r["path"] in ("continuous", "mixed", "discrete")
|
||||
|
||||
def test_insufficient_data_raises(self):
|
||||
with pytest.raises(ValidationError, match="FIP ID requires"):
|
||||
compute_fip_id(_rising_closes(50))
|
||||
|
||||
Reference in New Issue
Block a user