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]:
|
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.
|
"""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)
|
ID = sign(PRET) × (%neg − %pos)
|
||||||
|
|
||||||
Lower ID ⇒ smoother / more continuous path (for a winner: many small up days).
|
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
|
Higher ID ⇒ jumpy / discrete path (few large moves).
|
||||||
in neither numerator but remain in the denominator.
|
|
||||||
|
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
|
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:
|
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:
|
else:
|
||||||
sign = 0.0
|
sign = 0.0
|
||||||
fip = sign * (pct_neg - pct_pos)
|
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)))
|
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 = "continuous"
|
||||||
path_label = "smooth grind (continuous information)"
|
path_label = "smooth grind (continuous information)"
|
||||||
elif fip >= 0.25:
|
elif fip >= FIP_PATH_DISCRETE_MIN:
|
||||||
path = "discrete"
|
path = "discrete"
|
||||||
path_label = "jumpy path (discrete information)"
|
path_label = "jumpy path (discrete information)"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ function interpretation(
|
|||||||
|
|
||||||
case 'fip_id': {
|
case 'fip_id': {
|
||||||
// Display context only — not a production gate input.
|
// 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 label = typeof v.path_label === 'string' ? v.path_label : null;
|
||||||
const path = typeof v.path === 'string' ? v.path : null;
|
const path = typeof v.path === 'string' ? v.path : null;
|
||||||
if (label) {
|
if (label) {
|
||||||
@@ -201,8 +204,8 @@ function interpretation(
|
|||||||
}
|
}
|
||||||
const fip = num('fip_id');
|
const fip = num('fip_id');
|
||||||
if (fip == null) return null;
|
if (fip == null) return null;
|
||||||
if (fip <= -0.25) return { text: 'smooth grind (continuous)', tone: 'text-emerald-300' };
|
if (fip <= -0.08) 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) return { text: 'jumpy path (discrete)', tone: 'text-amber-300' };
|
||||||
return { text: 'mixed path', tone: 'text-gray-300' };
|
return { text: 'mixed path', tone: 'text-gray-300' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,8 @@ class TestComputeFipId:
|
|||||||
assert 0 <= result["score"] <= 100
|
assert 0 <= result["score"] <= 100
|
||||||
|
|
||||||
def test_jump_then_flat_is_more_discrete_than_steady(self):
|
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)]
|
steady = [100.0 * (1.002 ** i) for i in range(280)]
|
||||||
jumpy = [100.0] * 252
|
jumpy = [100.0] * 252
|
||||||
jumpy.append(100.0 * 1.5)
|
jumpy.append(100.0 * 1.5)
|
||||||
@@ -288,6 +290,25 @@ class TestComputeFipId:
|
|||||||
id_jumpy = compute_fip_id(jumpy)["fip_id"]
|
id_jumpy = compute_fip_id(jumpy)["fip_id"]
|
||||||
assert id_jumpy > id_steady
|
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):
|
def test_insufficient_data_raises(self):
|
||||||
with pytest.raises(ValidationError, match="FIP ID requires"):
|
with pytest.raises(ValidationError, match="FIP ID requires"):
|
||||||
compute_fip_id(_rising_closes(50))
|
compute_fip_id(_rising_closes(50))
|
||||||
|
|||||||
Reference in New Issue
Block a user