/* ═══════════════════════════════════════════════════════════════════ APP · Shadow Terminal Dashboard — wired to live Railway API ═══════════════════════════════════════════════════════════════════ */ ST_fmt.price = (n) => { if (n == null) return '—'; const a = Math.abs(n); if (a < 0.0001) return n.toExponential(3); if (a < 0.01) return n.toFixed(6); if (a < 1) return n.toFixed(4); if (a < 100) return n.toFixed(2); if (a < 10000) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('en-US', { maximumFractionDigits: 0 }); }; function genCandlesScaled(count, seed, basePrice, volPct = 0.16) { const rng = ST_rng(seed); let price = basePrice * (1 - volPct * 0.55); const out = []; for (let i = 0; i < count; i++) { const env = 0.93 + 0.07 * (i / count); const wave = (Math.sin(i / 13) * 0.5 + Math.sin(i / 5.3) * 0.16) * volPct; const target = basePrice * env * (1 + wave); const open = price; const close = open + (target - open) * 0.20 + (rng() - 0.5) * basePrice * volPct * 0.05; const hi = Math.max(open, close) + rng() * basePrice * volPct * 0.035; const lo = Math.min(open, close) - rng() * basePrice * volPct * 0.035; const vol = (0.5 + rng()) * (Math.abs(close - open) / basePrice * 40 + 0.4); out.push({ o: open, h: hi, l: lo, c: close, v: vol, t: i }); price = close; } const last = out[out.length - 1]; last.c = basePrice; last.h = Math.max(last.h, basePrice); last.l = Math.min(last.l, basePrice); return out; } const COIN_GROUP_MAP = { BTC:'institutional',ETH:'institutional',BNB:'institutional',XRP:'institutional', ADA:'institutional',TON:'institutional',MNT:'institutional',LTC:'institutional', SOL:'momentum',AVAX:'momentum',SUI:'momentum',NEAR:'momentum',APT:'momentum',SEI:'momentum', HYPE:'momentum', LINK:'defi',ATOM:'defi',ARB:'defi',OP:'defi',INJ:'defi',JUP:'defi',PYTH:'defi',DOT:'defi', AAVE:'defi',POL:'defi', DOGE:'meme',PEPE:'meme',WIF:'meme',BONK:'meme',POPCAT:'meme',SPX:'meme', USDC:'fee reserve',USDT:'fee reserve', }; function normApiCoins(raw) { /* API wraps in {coins:[...], count:N} */ const arr = Array.isArray(raw) ? raw : (Array.isArray(raw?.coins) ? raw.coins : []); return arr.map((c, idx) => ({ sym: c.symbol || c.sym || '???', name: c.full || c.name || c.symbol || '???', price: parseFloat(c.price || 0), chg: parseFloat(c.change_pct || 0), group: COIN_GROUP_MAP[c.symbol || c.sym] || 'momentum', vol: parseFloat(c.volume_24h || c.vol || 0), rank: idx + 1, })); } function normCandles(raw) { /* API wraps in {candles:[...]} */ const arr = Array.isArray(raw) ? raw : (Array.isArray(raw?.candles) ? raw.candles : []); if (!arr.length) return null; return arr.map((c, i) => ({ o: parseFloat(c.open || c.o || 0), h: parseFloat(c.high || c.h || 0), l: parseFloat(c.low || c.l || 0), c: parseFloat(c.close || c.c || 0), v: parseFloat(c.volume|| c.v || 0), t: i, })).filter(c => c.h > 0); } function App() { const [activeSym, setActiveSym] = React.useState('BTC'); const [coins, setCoins] = React.useState(ST_COINS); const [intel, setIntel] = React.useState(null); const [thoughts, setThoughts] = React.useState([ { time: '—', type: 'SCAN', message: 'Connecting to Shadow AI…', meta: '' }, ]); const [movers, setMovers] = React.useState([]); const [portfolioTotal, setPortfolioTotal] = React.useState(null); const [holdingsOpen, setHoldingsOpen] = React.useState(false); const [holdingsTotal, setHoldingsTotal] = React.useState(null); const [holdingsPnl, setHoldingsPnl] = React.useState(null); const [orders, setOrders] = React.useState([]); const [support, setSupport] = React.useState([]); const [resist, setResist] = React.useState([]); const [lows, setLows] = React.useState([]); const [position, setPosition] = React.useState(null); const coin = React.useMemo(() => coins.find(c => c.sym === activeSym) || coins[0] || ST_COINS[0], [coins, activeSym]); const FALLBACK = (sym, pr) => ({ price: pr, chg: 0, high: pr*1.01, low: pr*0.98, vol: 1, avg24: pr*0.999, channel: [pr*0.9, pr*1.1], }); const getPriceBase = (sym) => { /* Always prefer live coin data — the BASES below are only used on first render before the API has responded. */ const live_c = coins.find(x => x.sym === sym); if (live_c && live_c.price > 0) return FALLBACK(sym, live_c.price); /* True first-render fallback (no live data yet) */ const BASES = { BTC:{ price:104832.10, chg:0.42, high:105840, low:103200, vol:38.4, avg24:104394, channel:[101000,108000]}, ETH:{ price:3284.50, chg:1.15, high:3320, low:3210, vol:18.1, avg24:3247, channel:[3140,3380]}, SOL:{ price:86.24, chg:2.74, high:87.42, low:82.10, vol:1.84, avg24:83.94, channel:[80,92]}, }; if (BASES[sym]) return BASES[sym]; return FALLBACK(sym, 100); }; const [live, setLive] = React.useState(() => { const b = getPriceBase('BTC'); return { price:b.price, chg:b.chg, chgAbs:b.price*b.chg/100, high:b.high, low:b.low, vol:b.vol, lastTs:'now' }; }); const [candles, setCandles] = React.useState(() => genCandlesScaled(96, 7, 104832.10)); const ema21 = React.useMemo(() => ST_ema(candles, 21), [candles]); const ema55 = React.useMemo(() => ST_ema(candles, 55), [candles]); const vwap = React.useMemo(() => ST_vwap(candles), [candles]); const rsi = React.useMemo(() => ST_rsi(candles, 14), [candles]); /* ── 30s coin refresh ── */ const fetchCoins = React.useCallback(async () => { try { const raw = await ST_fetch('/market/coins'); /* API: {coins:[...], count:N} */ const normalised = normApiCoins(raw); if (normalised.length > 0) { setCoins(normalised); /* update live price for current active coin from fresh API data */ const fresh = normalised.find(c => c.sym === activeSym); if (fresh) { setLive(prev => ({ ...prev, price: fresh.price, chg: fresh.chg, chgAbs: fresh.price * fresh.chg / 100, high: Math.max(prev.high || 0, fresh.price), low: Math.min(prev.low || fresh.price * 2, fresh.price), lastTs: 'now', })); } } } catch (_) {} }, [activeSym]); React.useEffect(() => { fetchCoins(); const id = setInterval(fetchCoins, 30000); return () => clearInterval(id); }, []); /* ── intel bar ── normalize nested API response to flat shape */ React.useEffect(() => { ST_fetch('/market/intel').then(raw => { if (!raw) return; /* API returns: { btc_dominance:{value,label}, fear_greed:{value,label}, macro:{weekly_risk,risk_label}, equities:{sp500,nasdaq,dxy}, funding_bias:"BEARISH" } */ const eq = raw.equities?.sp500?.label || 'FLAT'; setIntel({ fear_greed: parseFloat(raw.fear_greed?.value ?? 25) || 25, btc_dominance: parseFloat(raw.btc_dominance?.value ?? 58.2) || 58.2, macro_risk: parseFloat(raw.macro?.weekly_risk ?? 42) || 42, equity_outlook: eq === 'BULL' || eq === 'BULLISH' ? 'BULLISH' : eq === 'BEAR' || eq === 'BEARISH' ? 'BEARISH' : 'NEUTRAL', equity_pct: null, funding_rate: raw.funding_bias === 'BEARISH' ? -0.0010 : raw.funding_bias === 'BULLISH' ? +0.0042 : 0.0002, regime: raw.btc_dominance?.label === 'BTC_STRONG' ? 'BEARISH' : raw.btc_dominance?.label === 'BTC_WEAK' ? 'BULLISH' : 'NEUTRAL', regime_flip_note: 'awaiting BTC structure change', }); }).catch(() => {}); }, []); /* ── portfolio total (Bybit, if reachable) ── */ React.useEffect(() => { ST_fetch('/health') .then(h => { /* API: {status, bybit_total, live_prices, feed_active} */ const total = parseFloat(h.bybit_total || h.portfolio_total || h.total_value || 0); setPortfolioTotal(total > 0 ? total : null); }).catch(() => {}); }, []); /* ── my holdings total (manual, API-independent) ── */ const fetchHoldings = React.useCallback(() => { ST_fetch('/market/assets').then(d => { setHoldingsTotal(parseFloat(d.total_value || 0) || null); setHoldingsPnl(d.total_value > 0 ? parseFloat(d.total_pnl_pct || 0) : null); }).catch(() => {}); }, []); React.useEffect(() => { fetchHoldings(); }, [fetchHoldings]); /* ── thoughts: use shadow/interesting coins + SSE stream ── */ React.useEffect(() => { let evtSrc = null; /* Seed thought stream from interesting coins (Shadow's observations) */ ST_fetch('/shadow/interesting').then(raw => { const coinArr = Array.isArray(raw) ? raw : (Array.isArray(raw?.coins) ? raw.coins : []); if (coinArr.length > 0) { const now = new Date(); const fmt = (d) => d.toISOString().slice(11,19); const events = coinArr.slice(0,8).map((c, i) => { const sym = c.symbol || c.sym || '?'; const dir = c.momentum?.direction || 'FLAT'; const pct = parseFloat(c.change_pct || 0); const t = new Date(now - i * 60000); const type = Math.abs(pct) > 3 ? 'SIGNAL' : dir !== 'FLAT' ? 'HYPOTHESIS' : 'SCAN'; const msg = `${sym} ${pct >= 0 ? '+' : ''}${pct.toFixed(2)}% · momentum ${dir} · vol ${c.momentum?.vol_change >= 0 ? '+' : ''}${parseFloat(c.momentum?.vol_change || 0).toFixed(1)}%`; return { time: fmt(t), type, message: msg, meta: `${sym} · live scan` }; }); setThoughts(events); } }).catch(() => {}); /* SSE live stream */ try { evtSrc = new EventSource(window.ST_API + '/shadow/thoughts'); evtSrc.onmessage = (evt) => { try { const e = JSON.parse(evt.data); setThoughts(prev => [{ time: e.timestamp || e.time || new Date().toISOString().slice(11,19), type: (e.type || 'SCAN').toUpperCase(), message: e.message || e.msg || '', meta: e.meta || '', }, ...prev].slice(0, 60)); } catch (_) {} }; evtSrc.onerror = () => evtSrc.close(); } catch (_) {} return () => { if (evtSrc) evtSrc.close(); }; }, []); /* ── movers from predictions + top movers by change ── */ React.useEffect(() => { ST_fetch('/shadow/predictions').then(raw => { /* API: {predictions:[], stats:{}, count:0} */ const predsArr = Array.isArray(raw) ? raw : (Array.isArray(raw?.predictions) ? raw.predictions : []); const coinMap = {}; coins.forEach(c => { coinMap[c.sym] = c; }); if (predsArr.length > 0) { setMovers(predsArr.slice(0,4).map(p => { const c = coinMap[p.sym || p.symbol] || {}; return { sym: p.sym||p.symbol, price: c.price||parseFloat(p.entry||0), chg: c.chg||0, group: c.group||'momentum', signal: p.dir||'LONG', conf: parseFloat(p.confidence||p.conf||0) }; })); } else { /* Fallback: top movers by absolute % change from coins list */ const sorted = [...coins].sort((a,b) => Math.abs(b.chg) - Math.abs(a.chg)).slice(0,4); setMovers(sorted.map(c => ({ ...c, signal: null, conf: 0 }))); } }).catch(() => { /* Fallback movers from coins */ const sorted = [...coins].sort((a,b) => Math.abs(b.chg) - Math.abs(a.chg)).slice(0,4); setMovers(sorted.map(c => ({ ...c, signal: null, conf: 0 }))); }); }, [coins]); /* ── per-coin data on symbol change ── */ React.useEffect(() => { const b = getPriceBase(activeSym); setLive({ price:b.price, chg:b.chg, chgAbs:b.price*b.chg/100, high:b.high, low:b.low, vol:b.vol, lastTs:'now' }); setCandles(genCandlesScaled(96, activeSym.charCodeAt(0) + activeSym.length * 7, b.price)); setOrders([]); setSupport([]); setResist([]); setLows([]); setPosition(null); Promise.allSettled([ ST_fetch(`/market/candles?symbol=${activeSym}&interval=D&limit=200`), ST_fetch(`/market/orders?symbol=${activeSym}`), ST_fetch(`/market/balance/${activeSym}`), ST_fetch(`/market/lows?symbol=${activeSym}`), ST_fetch(`/shadow/snapshot/${activeSym}`), ]).then(([candR, ordR, balR, lowR, snapR]) => { /* candles: API → {candles:[]} */ if (candR.status==='fulfilled') { const nc = normCandles(candR.value); if (nc?.length) setCandles(nc); } /* orders: API → {orders:[], count:N} */ if (ordR.status==='fulfilled') { const ordArr = Array.isArray(ordR.value) ? ordR.value : (ordR.value?.orders || []); setOrders(ordArr.map(o => ({ price: parseFloat(o.price || o.order_price || 0), qty: parseFloat(o.qty || o.order_qty || o.quantity || 0), verdict: o.verdict || 'AMBER', })).filter(o => o.price > 0)); } /* balance: API → {symbol, qty, usdt_value, avg_cost, current_price, has_position} */ if (balR.status==='fulfilled' && balR.value) { const qty = parseFloat(balR.value.qty || 0); if (qty > 0) setPosition({ qty, avg: parseFloat(balR.value.avg_cost || 0) }); } /* lows: API → {symbol, interval, lows:[{price, date_str, days_ago}]}. pct ("from now") is computed in the component against the LIVE price — not here — so it never uses a stale/synthetic price (was showing wrong %). */ if (lowR.status==='fulfilled') { const lowArr = lowR.value?.lows || lowR.value || []; if (Array.isArray(lowArr) && lowArr.length) { setLows(lowArr.slice(0,5).map(l => ({ p: parseFloat(l.price || 0), d: l.date_str || l.date || '—', }))); } } /* snapshot: API → {symbol, snapshot:{...}} */ if (snapR.status==='fulfilled' && snapR.value) { const s = snapR.value?.snapshot || snapR.value; if (Array.isArray(s.support_zones)) setSupport(s.support_zones.map(z => ({ lo: z.lo||z.low, hi: z.hi||z.high }))); if (Array.isArray(s.resistance_zones)) setResist(s.resistance_zones.map(z => ({ lo: z.lo||z.low, hi: z.hi||z.high }))); } }); }, [activeSym]); /* ── live timestamp updater (lightweight — no SVG repaint) ── */ React.useEffect(() => { const start = Date.now(); const id = setInterval(() => { const elapsed = Math.floor((Date.now() - start) / 1000); setLive(prev => ({ ...prev, lastTs: elapsed < 60 ? `${elapsed}s ago` : `${Math.floor(elapsed / 60)}m ago`, })); }, 10000); /* every 10s — just updates the timestamp label, not the price */ return () => clearInterval(id); }, [activeSym]); return (