Add human status labels to all indicators and small UI tweaks.
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m13s
Deploy / deploy (push) Successful in 40s

Interpret RSI, ADX, EMA, ATR, volume profile, and pivots like EMA cross; tighten exit timeline spacing; risk presets are 1/2/3/5%.
This commit is contained in:
2026-07-14 11:24:08 +02:00
parent f309bd5691
commit cad1be96da
3 changed files with 102 additions and 19 deletions
@@ -22,11 +22,11 @@ export function ExitPlanPanel({ plan, direction }: { plan: ExitPlan; direction:
<span className="text-[11.5px] font-medium text-gray-300">{plan.headline}</span> <span className="text-[11.5px] font-medium text-gray-300">{plan.headline}</span>
</div> </div>
<ol className="relative mt-4"> <ol className="relative mt-3">
{steps.map((step, i) => { {steps.map((step, i) => {
const isLast = i === steps.length - 1; const isLast = i === steps.length - 1;
return ( return (
<li key={step.title} className="relative flex gap-3"> <li key={step.title} className="relative flex gap-2.5">
{/* Spine column: continuous connector from this node into the next */} {/* Spine column: continuous connector from this node into the next */}
<div className="relative flex w-5 shrink-0 flex-col items-center self-stretch"> <div className="relative flex w-5 shrink-0 flex-col items-center self-stretch">
<span <span
@@ -43,15 +43,15 @@ export function ExitPlanPanel({ plan, direction }: { plan: ExitPlan; direction:
)} )}
</div> </div>
<div className={`min-w-0 flex-1 pt-0.5 ${isLast ? 'pb-0' : 'pb-5'}`}> <div className={`min-w-0 flex-1 pt-0.5 ${isLast ? 'pb-0' : 'pb-2.5'}`}>
<p className="text-[12px] font-medium text-gray-200">{step.title}</p> <p className="text-[12px] font-medium leading-tight text-gray-200">{step.title}</p>
{step.primary && ( {step.primary && (
<p className="num mt-0.5 text-[13px] font-semibold tracking-tight text-gray-100"> <p className="num mt-0.5 text-[13px] font-semibold leading-tight tracking-tight text-gray-100">
{step.primary} {step.primary}
</p> </p>
)} )}
{step.detail && ( {step.detail && (
<p className="mt-0.5 text-[11.5px] leading-snug text-gray-500">{step.detail}</p> <p className="mt-0.5 text-[11px] leading-snug text-gray-500">{step.detail}</p>
)} )}
</div> </div>
</li> </li>
@@ -79,25 +79,108 @@ function clusterLevels(nums: number[], refPrice?: number): { shown: string[]; mo
return { shown, more: clusters.length - Math.min(clusters.length, MAX_CLUSTERS) }; return { shown, more: clusters.length - Math.min(clusters.length, MAX_CLUSTERS) };
} }
/** The one-line human read of an indicator, where its meaning is standard. */ /**
function interpretation(result: IndicatorResult): { text: string; tone: string } | null { * One-line human read of an indicator — same slot as EMA cross's
* bullish/bearish/neutral. Vocab matches what the metric means (directional
* vs regime), not forced into bullish/bearish for everything.
*/
function interpretation(
result: IndicatorResult,
refPrice?: number,
): { text: string; tone: string } | null {
const v = result.values as Record<string, unknown>; const v = result.values as Record<string, unknown>;
const num = (k: string) => (typeof v[k] === 'number' ? (v[k] as number) : null); const num = (k: string) => (typeof v[k] === 'number' ? (v[k] as number) : null);
switch (result.indicator_type) { const type = result.indicator_type.toLowerCase();
case 'RSI': {
const rsi = num('rsi') ?? num('RSI') ?? num('value'); switch (type) {
case 'rsi': {
const rsi = num('rsi') ?? num('value');
if (rsi == null) return null; if (rsi == null) return null;
if (rsi >= 70) return { text: 'overbought', tone: 'text-red-300' }; if (rsi >= 70) return { text: 'overbought', tone: 'text-red-300' };
if (rsi <= 30) return { text: 'oversold', tone: 'text-emerald-300' }; if (rsi <= 30) return { text: 'oversold', tone: 'text-emerald-300' };
return { text: 'neutral zone', tone: 'text-gray-400' }; if (rsi >= 55) return { text: 'bullish momentum', tone: 'text-emerald-300' };
if (rsi <= 45) return { text: 'bearish momentum', tone: 'text-red-300' };
return { text: 'neutral', tone: 'text-gray-400' };
} }
case 'ADX': {
const adx = num('adx') ?? num('ADX') ?? num('value'); case 'adx': {
const adx = num('adx') ?? num('value');
const plusDi = num('plus_di');
const minusDi = num('minus_di');
if (adx == null) return null; if (adx == null) return null;
if (adx >= 40) return { text: 'strong trend', tone: 'text-emerald-300' }; if (adx < 20) return { text: 'no trend / range', tone: 'text-gray-400' };
if (adx >= 25) return { text: 'trending', tone: 'text-gray-300' }; if (adx < 25) return { text: 'trend forming', tone: 'text-gray-300' };
return { text: 'weak / no trend', tone: 'text-gray-400' }; const bullish = plusDi != null && minusDi != null && plusDi > minusDi;
const bearish = plusDi != null && minusDi != null && minusDi > plusDi;
if (adx >= 40) {
if (bullish) return { text: 'strong bullish trend', tone: 'text-emerald-300' };
if (bearish) return { text: 'strong bearish trend', tone: 'text-red-300' };
return { text: 'strong trend', tone: 'text-emerald-300' };
} }
if (bullish) return { text: 'bullish trend', tone: 'text-emerald-300' };
if (bearish) return { text: 'bearish trend', tone: 'text-red-300' };
return { text: 'trending', tone: 'text-gray-300' };
}
case 'ema': {
const ema = num('ema');
const close = num('latest_close');
if (ema == null || close == null || ema === 0) return null;
const pct = ((close - ema) / ema) * 100;
if (pct >= 0.5) return { text: 'above EMA · bullish', tone: 'text-emerald-300' };
if (pct <= -0.5) return { text: 'below EMA · bearish', tone: 'text-red-300' };
return { text: 'at EMA · neutral', tone: 'text-gray-400' };
}
case 'atr': {
const atrPct = num('atr_percent');
if (atrPct == null) return null;
if (atrPct < 1.5) return { text: 'compressed', tone: 'text-emerald-300' };
if (atrPct < 3) return { text: 'normal volatility', tone: 'text-gray-400' };
if (atrPct < 5) return { text: 'elevated', tone: 'text-amber-300' };
return { text: 'high volatility', tone: 'text-red-300' };
}
case 'volume_profile': {
const vaLow = num('value_area_low');
const vaHigh = num('value_area_high');
const poc = num('poc');
const price = refPrice ?? num('latest_close');
if (price == null || vaLow == null || vaHigh == null) return null;
if (price > vaHigh) return { text: 'above value · extended', tone: 'text-amber-300' };
if (price < vaLow) return { text: 'below value · discounted', tone: 'text-emerald-300' };
// Inside value area: near POC vs mid-range.
if (poc != null) {
const span = Math.max(vaHigh - vaLow, Math.abs(price) * 0.001);
if (Math.abs(price - poc) / span <= 0.15) {
return { text: 'at value · balanced', tone: 'text-gray-300' };
}
}
return { text: 'in value area', tone: 'text-gray-400' };
}
case 'pivot_points': {
const price = refPrice;
if (price == null || !(price > 0)) return null;
const highs = parseNums(v.swing_highs) ?? [];
const lows = parseNums(v.swing_lows) ?? [];
// parseNums requires ≥2 elements; allow singletons for pivots.
const rawHighs = Array.isArray(v.swing_highs)
? (v.swing_highs as unknown[]).filter((x): x is number => typeof x === 'number')
: highs;
const rawLows = Array.isArray(v.swing_lows)
? (v.swing_lows as unknown[]).filter((x): x is number => typeof x === 'number')
: lows;
if (rawHighs.length === 0 && rawLows.length === 0) return null;
const near = (level: number) => Math.abs(level - price) / price <= 0.02;
const nearHigh = rawHighs.some(near);
const nearLow = rawLows.some(near);
if (nearHigh && nearLow) return { text: 'congested structure', tone: 'text-gray-400' };
if (nearLow) return { text: 'near support pivot', tone: 'text-emerald-300' };
if (nearHigh) return { text: 'near resistance pivot', tone: 'text-red-300' };
return { text: 'between pivots', tone: 'text-gray-400' };
}
default: default:
return null; return null;
} }
@@ -164,7 +247,7 @@ function IndicatorCard({ symbol, type, refPrice }: { symbol: string; type: strin
{query.data && ( {query.data && (
<> <>
{(() => { {(() => {
const read = interpretation(query.data); const read = interpretation(query.data, refPrice);
return read ? ( return read ? (
<p className={`mt-1.5 text-sm font-semibold ${read.tone}`}>{read.text}</p> <p className={`mt-1.5 text-sm font-semibold ${read.tone}`}>{read.text}</p>
) : null; ) : null;
@@ -529,7 +529,7 @@ function SetupCard({ setup, action, currentPrice, risk, regime, exitPolicy, sele
); );
} }
const RISK_PRESETS = [0.5, 1, 2, 3]; const RISK_PRESETS = [1, 2, 3, 5];
/** Compact, set-once sizing controls: a clean account field (no spinners) and a /** Compact, set-once sizing controls: a clean account field (no spinners) and a
* segmented risk-% selector — risk is almost always one of a few values. */ * segmented risk-% selector — risk is almost always one of a few values. */