feat: show FIP path-smoothness in ticker technicals
Display-only Da/Gurun/Warachka information discreteness on the ticker indicator panel. Shared compute with the backtest harness; not wired into gate or rank.
This commit is contained in:
@@ -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:
|
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
|
try:
|
||||||
close[i-21] (231 daily returns ending one month before as-of).
|
return float(compute_fip_id(closes, as_of_index=i)["fip_id"])
|
||||||
|
except (ValidationError, KeyError, TypeError, ValueError):
|
||||||
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:
|
|
||||||
return None
|
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(
|
def _signal_values(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ MIN_BARS: dict[str, int] = {
|
|||||||
"atr": 15,
|
"atr": 15,
|
||||||
"volume_profile": 20,
|
"volume_profile": 20,
|
||||||
"pivot_points": 5,
|
"pivot_points": 5,
|
||||||
|
"fip_id": 253, # 12-1 formation: need index i-252
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_PERIODS: dict[str, int] = {
|
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(
|
def compute_ema_cross(
|
||||||
closes: list[float],
|
closes: list[float],
|
||||||
short_period: int = 20,
|
short_period: int = 20,
|
||||||
@@ -451,7 +517,15 @@ def compute_ema_cross(
|
|||||||
# Supported indicator types
|
# 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)
|
result = compute_volume_profile(highs, lows, closes, volumes)
|
||||||
elif indicator_type == "pivot_points":
|
elif indicator_type == "pivot_points":
|
||||||
result = compute_pivot_points(highs, lows, closes)
|
result = compute_pivot_points(highs, lows, closes)
|
||||||
|
elif indicator_type == "fip_id":
|
||||||
|
result = compute_fip_id(closes)
|
||||||
else:
|
else:
|
||||||
raise ValidationError(f"Unknown indicator type: {indicator_type}")
|
raise ValidationError(f"Unknown indicator type: {indicator_type}")
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,15 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { getIndicator, getEMACross } from '../../api/indicators';
|
import { getIndicator, getEMACross } from '../../api/indicators';
|
||||||
import type { IndicatorResult } from '../../lib/types';
|
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<string, string> = {
|
const INDICATOR_LABELS: Record<string, string> = {
|
||||||
RSI: 'RSI',
|
RSI: 'RSI',
|
||||||
@@ -11,6 +19,7 @@ const INDICATOR_LABELS: Record<string, string> = {
|
|||||||
ATR: 'ATR · volatility',
|
ATR: 'ATR · volatility',
|
||||||
volume_profile: 'Volume profile',
|
volume_profile: 'Volume profile',
|
||||||
pivot_points: 'Pivot points',
|
pivot_points: 'Pivot points',
|
||||||
|
fip_id: 'FIP · path smoothness',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface IndicatorSelectorProps {
|
interface IndicatorSelectorProps {
|
||||||
@@ -181,6 +190,22 @@ function interpretation(
|
|||||||
return { text: 'between pivots', tone: 'text-gray-400' };
|
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -226,11 +251,16 @@ function IndicatorCard({ symbol, type, refPrice }: { symbol: string; type: strin
|
|||||||
<h4 className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">
|
<h4 className="num text-[10px] uppercase tracking-[0.16em] text-gray-500">
|
||||||
{INDICATOR_LABELS[type] ?? type}
|
{INDICATOR_LABELS[type] ?? type}
|
||||||
</h4>
|
</h4>
|
||||||
{query.data && (
|
{query.data && type.toLowerCase() !== 'fip_id' && (
|
||||||
<span className="num text-[10px] text-gray-600" title={`${query.data.bars_used} bars used · normalized score`}>
|
<span className="num text-[10px] text-gray-600" title={`${query.data.bars_used} bars used · normalized score`}>
|
||||||
score {query.data.score.toFixed(2)}
|
score {query.data.score.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{query.data && type.toLowerCase() === 'fip_id' && (
|
||||||
|
<span className="num text-[10px] text-gray-600" title="Display only — not used by the production gate">
|
||||||
|
context only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{query.isLoading && (
|
{query.isLoading && (
|
||||||
@@ -253,13 +283,25 @@ function IndicatorCard({ symbol, type, refPrice }: { symbol: string; type: strin
|
|||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
<dl className="mt-2.5 space-y-1.5">
|
<dl className="mt-2.5 space-y-1.5">
|
||||||
{Object.entries(query.data.values).map(([key, val]) => (
|
{Object.entries(query.data.values)
|
||||||
<ValueRow key={key} name={key} val={val} refPrice={refPrice} />
|
.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]) => (
|
||||||
|
<ValueRow key={key} name={key} val={val} refPrice={refPrice} />
|
||||||
|
))}
|
||||||
{Object.keys(query.data.values).length === 0 && (
|
{Object.keys(query.data.values).length === 0 && (
|
||||||
<p className="text-xs text-gray-500">No values.</p>
|
<p className="text-xs text-gray-500">No values.</p>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
{type.toLowerCase() === 'fip_id' && (
|
||||||
|
<p className="mt-2 text-[11px] leading-snug text-gray-600">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from app.services.indicator_service import (
|
|||||||
compute_atr,
|
compute_atr,
|
||||||
compute_ema,
|
compute_ema,
|
||||||
compute_ema_cross,
|
compute_ema_cross,
|
||||||
|
compute_fip_id,
|
||||||
compute_pivot_points,
|
compute_pivot_points,
|
||||||
compute_rsi,
|
compute_rsi,
|
||||||
compute_volume_profile,
|
compute_volume_profile,
|
||||||
@@ -262,3 +263,31 @@ class TestComputeEMACross:
|
|||||||
closes = _rising_closes(30)
|
closes = _rising_closes(30)
|
||||||
with pytest.raises(ValidationError, match="EMA Cross requires"):
|
with pytest.raises(ValidationError, match="EMA Cross requires"):
|
||||||
compute_ema_cross(closes, short_period=20, long_period=50)
|
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))
|
||||||
|
|||||||
Reference in New Issue
Block a user