diff --git a/app/services/indicator_service.py b/app/services/indicator_service.py index d7ffba5..2178cba 100644 --- a/app/services/indicator_service.py +++ b/app/services/indicator_service.py @@ -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: diff --git a/frontend/src/components/ticker/IndicatorSelector.tsx b/frontend/src/components/ticker/IndicatorSelector.tsx index c00a974..a4d495c 100644 --- a/frontend/src/components/ticker/IndicatorSelector.tsx +++ b/frontend/src/components/ticker/IndicatorSelector.tsx @@ -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' }; } diff --git a/tests/unit/test_indicator_service.py b/tests/unit/test_indicator_service.py index e29e71e..80b1e56 100644 --- a/tests/unit/test_indicator_service.py +++ b/tests/unit/test_indicator_service.py @@ -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))