diff --git a/app/services/backtest_service.py b/app/services/backtest_service.py index 48d1211..01bf89e 100644 --- a/app/services/backtest_service.py +++ b/app/services/backtest_service.py @@ -819,39 +819,14 @@ def _realized_vol_6m(closes: list[float], i: int) -> float | None: def _fip_id(closes: list[float], i: int) -> float | None: - """Da/Gurun/Warachka information discreteness over the 12-1 formation window. + """Point-in-time FIP ID for the signal harness; delegates to indicator_service.""" + from app.services.indicator_service import compute_fip_id + from app.exceptions import ValidationError - Formation matches ``mom_12_1``: cumulative return from close[i-252] to - close[i-21] (231 daily returns ending one month before as-of). - - ID = sign(PRET) × (%neg − %pos) - - where %pos / %neg are fractions of up / down days over the formation window - (zero-return days count in neither numerator, but remain in the denominator). - Lower ID = smoother / more continuous path → expect negative cross-sectional - IC (continuous-information winners outperform). - """ - if i - 252 < 0 or closes[i - 252] <= 0 or closes[i - 21] <= 0: + try: + return float(compute_fip_id(closes, as_of_index=i)["fip_id"]) + except (ValidationError, KeyError, TypeError, ValueError): return None - pret = closes[i - 21] / closes[i - 252] - 1.0 - rets: list[float] = [] - for k in range(i - 251, i - 20): - prev = closes[k - 1] - if prev <= 0: - return None - rets.append(closes[k] / prev - 1.0) - if len(rets) < 200: - return None - n = len(rets) - pct_pos = sum(1 for r in rets if r > 0) / n - pct_neg = sum(1 for r in rets if r < 0) / n - if pret > 0: - sign = 1.0 - elif pret < 0: - sign = -1.0 - else: - sign = 0.0 - return sign * (pct_neg - pct_pos) def _signal_values( diff --git a/app/services/indicator_service.py b/app/services/indicator_service.py index 267e96a..d7ffba5 100644 --- a/app/services/indicator_service.py +++ b/app/services/indicator_service.py @@ -28,6 +28,7 @@ MIN_BARS: dict[str, int] = { "atr": 15, "volume_profile": 20, "pivot_points": 5, + "fip_id": 253, # 12-1 formation: need index i-252 } DEFAULT_PERIODS: dict[str, int] = { @@ -407,6 +408,71 @@ def compute_pivot_points( } +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. + + Display / research context only — **not** used by the activation gate or + production rank. Same window as residual 12-1 momentum: cumulative return + from close[i-252] to close[i-21] (skip last month). + + 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. + """ + 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: + raise ValidationError( + f"FIP ID requires at least 253 bars with positive formation closes, " + f"got {len(closes)}" + ) + pret = closes[i - 21] / closes[i - 252] - 1.0 + rets: list[float] = [] + for k in range(i - 251, i - 20): + prev = closes[k - 1] + if prev <= 0: + raise ValidationError("FIP ID requires positive closes in the formation window") + rets.append(closes[k] / prev - 1.0) + if len(rets) < 200: + raise ValidationError( + f"FIP ID requires ≥200 daily returns in formation, got {len(rets)}" + ) + n = len(rets) + pct_pos = sum(1 for r in rets if r > 0) / n + pct_neg = sum(1 for r in rets if r < 0) / n + if pret > 0: + sign = 1.0 + elif pret < 0: + sign = -1.0 + else: + sign = 0.0 + fip = sign * (pct_neg - pct_pos) + # Map [-1, 1] → score where lower ID (smoother) is higher for display only. + score = max(0.0, min(100.0, 50.0 * (1.0 - fip))) + if fip <= -0.25: + path = "continuous" + path_label = "smooth grind (continuous information)" + elif fip >= 0.25: + path = "discrete" + path_label = "jumpy path (discrete information)" + else: + path = "mixed" + path_label = "mixed path" + return { + "fip_id": round(fip, 4), + "pret_12_1": round(pret, 4), + "pct_up_days": round(pct_pos * 100.0, 1), + "pct_down_days": round(pct_neg * 100.0, 1), + "formation_days": n, + "path": path, + "path_label": path_label, + "display_only": True, + "note": "Not used by the production gate or rank — context only.", + "score": round(score, 4), + } + + def compute_ema_cross( closes: list[float], short_period: int = 20, @@ -451,7 +517,15 @@ def compute_ema_cross( # Supported indicator types # --------------------------------------------------------------------------- -INDICATOR_TYPES = {"adx", "ema", "rsi", "atr", "volume_profile", "pivot_points"} +INDICATOR_TYPES = { + "adx", + "ema", + "rsi", + "atr", + "volume_profile", + "pivot_points", + "fip_id", +} # --------------------------------------------------------------------------- @@ -514,6 +588,8 @@ async def get_indicator( result = compute_volume_profile(highs, lows, closes, volumes) elif indicator_type == "pivot_points": result = compute_pivot_points(highs, lows, closes) + elif indicator_type == "fip_id": + result = compute_fip_id(closes) else: raise ValidationError(f"Unknown indicator type: {indicator_type}") diff --git a/frontend/src/components/ticker/IndicatorSelector.tsx b/frontend/src/components/ticker/IndicatorSelector.tsx index d285c6a..c00a974 100644 --- a/frontend/src/components/ticker/IndicatorSelector.tsx +++ b/frontend/src/components/ticker/IndicatorSelector.tsx @@ -2,7 +2,15 @@ import { useQuery } from '@tanstack/react-query'; import { getIndicator, getEMACross } from '../../api/indicators'; import type { IndicatorResult } from '../../lib/types'; -const INDICATOR_TYPES = ['RSI', 'ADX', 'EMA', 'ATR', 'volume_profile', 'pivot_points'] as const; +const INDICATOR_TYPES = [ + 'RSI', + 'ADX', + 'EMA', + 'ATR', + 'volume_profile', + 'pivot_points', + 'fip_id', +] as const; const INDICATOR_LABELS: Record = { RSI: 'RSI', @@ -11,6 +19,7 @@ const INDICATOR_LABELS: Record = { ATR: 'ATR · volatility', volume_profile: 'Volume profile', pivot_points: 'Pivot points', + fip_id: 'FIP · path smoothness', }; interface IndicatorSelectorProps { @@ -181,6 +190,22 @@ function interpretation( return { text: 'between pivots', tone: 'text-gray-400' }; } + case 'fip_id': { + // Display context only — not a production gate input. + const label = typeof v.path_label === 'string' ? v.path_label : null; + const path = typeof v.path === 'string' ? v.path : null; + if (label) { + if (path === 'continuous') return { text: label, tone: 'text-emerald-300' }; + if (path === 'discrete') return { text: label, tone: 'text-amber-300' }; + return { text: label, tone: 'text-gray-300' }; + } + 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' }; + return { text: 'mixed path', tone: 'text-gray-300' }; + } + default: return null; } @@ -226,11 +251,16 @@ function IndicatorCard({ symbol, type, refPrice }: { symbol: string; type: strin

{INDICATOR_LABELS[type] ?? type}

- {query.data && ( + {query.data && type.toLowerCase() !== 'fip_id' && ( score {query.data.score.toFixed(2)} )} + {query.data && type.toLowerCase() === 'fip_id' && ( + + context only + + )} {query.isLoading && ( @@ -253,13 +283,25 @@ function IndicatorCard({ symbol, type, refPrice }: { symbol: string; type: strin ) : null; })()}
- {Object.entries(query.data.values).map(([key, val]) => ( - - ))} + {Object.entries(query.data.values) + .filter(([key]) => { + // Hide meta / prose fields already shown in the interpretation line. + if (type.toLowerCase() !== 'fip_id') return true; + return !['path', 'path_label', 'display_only', 'note'].includes(key); + }) + .map(([key, val]) => ( + + ))} {Object.keys(query.data.values).length === 0 && (

No values.

)}
+ {type.toLowerCase() === 'fip_id' && ( +

+ How smooth the past ~12-month move was (skip last month). Lower FIP usually + means a steadier grind. Not used to qualify or rank trades. +

+ )} )} diff --git a/tests/unit/test_indicator_service.py b/tests/unit/test_indicator_service.py index 6d0fb0b..e29e71e 100644 --- a/tests/unit/test_indicator_service.py +++ b/tests/unit/test_indicator_service.py @@ -8,6 +8,7 @@ from app.services.indicator_service import ( compute_atr, compute_ema, compute_ema_cross, + compute_fip_id, compute_pivot_points, compute_rsi, compute_volume_profile, @@ -262,3 +263,31 @@ class TestComputeEMACross: closes = _rising_closes(30) with pytest.raises(ValidationError, match="EMA Cross requires"): compute_ema_cross(closes, short_period=20, long_period=50) + + +# --------------------------------------------------------------------------- +# FIP ID (display / research context — not a gate) +# --------------------------------------------------------------------------- + +class TestComputeFipId: + def test_steady_climber_is_continuous(self): + # Many small up days → low (negative) ID for a positive-return path. + closes = [100.0 * (1.002 ** i) for i in range(280)] + result = compute_fip_id(closes) + assert result["fip_id"] < 0 + assert result["path"] == "continuous" + assert result["display_only"] is True + assert 0 <= result["score"] <= 100 + + def test_jump_then_flat_is_more_discrete_than_steady(self): + steady = [100.0 * (1.002 ** i) for i in range(280)] + jumpy = [100.0] * 252 + jumpy.append(100.0 * 1.5) + jumpy.extend([100.0 * 1.5] * 40) + id_steady = compute_fip_id(steady)["fip_id"] + id_jumpy = compute_fip_id(jumpy)["fip_id"] + assert id_jumpy > id_steady + + def test_insufficient_data_raises(self): + with pytest.raises(ValidationError, match="FIP ID requires"): + compute_fip_id(_rising_closes(50))