-
{step.title}
+
+
{step.title}
{step.primary && (
-
+
{step.primary}
)}
{step.detail && (
-
{step.detail}
+
{step.detail}
)}
diff --git a/frontend/src/components/ticker/IndicatorSelector.tsx b/frontend/src/components/ticker/IndicatorSelector.tsx
index 53991f2..d285c6a 100644
--- a/frontend/src/components/ticker/IndicatorSelector.tsx
+++ b/frontend/src/components/ticker/IndicatorSelector.tsx
@@ -79,25 +79,108 @@ function clusterLevels(nums: number[], refPrice?: number): { shown: string[]; mo
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
;
const num = (k: string) => (typeof v[k] === 'number' ? (v[k] as number) : null);
- switch (result.indicator_type) {
- case 'RSI': {
- const rsi = num('rsi') ?? num('RSI') ?? num('value');
+ const type = result.indicator_type.toLowerCase();
+
+ switch (type) {
+ case 'rsi': {
+ const rsi = num('rsi') ?? num('value');
if (rsi == null) return null;
if (rsi >= 70) return { text: 'overbought', tone: 'text-red-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 >= 40) return { text: 'strong trend', tone: 'text-emerald-300' };
- if (adx >= 25) return { text: 'trending', tone: 'text-gray-300' };
- return { text: 'weak / no trend', tone: 'text-gray-400' };
+ if (adx < 20) return { text: 'no trend / range', tone: 'text-gray-400' };
+ if (adx < 25) return { text: 'trend forming', tone: 'text-gray-300' };
+ 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:
return null;
}
@@ -164,7 +247,7 @@ function IndicatorCard({ symbol, type, refPrice }: { symbol: string; type: strin
{query.data && (
<>
{(() => {
- const read = interpretation(query.data);
+ const read = interpretation(query.data, refPrice);
return read ? (
{read.text}
) : null;
diff --git a/frontend/src/components/ticker/RecommendationPanel.tsx b/frontend/src/components/ticker/RecommendationPanel.tsx
index 58a5971..74140d9 100644
--- a/frontend/src/components/ticker/RecommendationPanel.tsx
+++ b/frontend/src/components/ticker/RecommendationPanel.tsx
@@ -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
* segmented risk-% selector — risk is almost always one of a few values. */