/* ═══════════════════════════════════════════════════════════════════ st-chart.jsx — SHARED PRO CHART (dashboard · coin · trade) v3 Rendering core = lightweight-charts (TradingView's open-source engine — the same candle DNA as Bybit's chart): native wheel zoom, drag pan, pixel-crisp candles, auto-scaling price axis, kinetic scroll. We add: working timeframes, EMA21/55 + VWAP overlays, volume pane, RSI pane (synced), S/R zones and order price-lines, live OHLC readout, 30s auto-refresh pinned to the live edge. Usage (unchanged from v2): Requires the lightweight-charts standalone script (see index.html). ═══════════════════════════════════════════════════════════════════ */ /* Timeframe presets = candle interval + how many bars to show on first paint. Fewer visible bars = bigger, taller candles — tuned for a Bybit-style zoom (15m is the default and is the most zoomed-in / readable). Order here is the order the buttons render in. */ const ST_TF = { '1m': { iv: '1', bars: 55 }, // ~55 min, big candles '15m': { iv: '15', bars: 50 }, // ~12 h — super zoomed (default) '30m': { iv: '30', bars: 56 }, // ~28 h '1h': { iv: '60', bars: 64 }, // ~2.7 d '4h': { iv: '240', bars: 70 }, // ~11 d '1d': { iv: 'D', bars: 75 }, // ~2.5 mo '1w': { iv: 'W', bars: 60 }, // ~14 mo '1M': { iv: 'M', bars: 36 }, // 3 yr of monthly }; function stPrecision(p) { const a = Math.abs(p || 1); if (a >= 1000) return { precision: 1, minMove: 0.1 }; if (a >= 50) return { precision: 2, minMove: 0.01 }; if (a >= 1) return { precision: 3, minMove: 0.001 }; if (a >= 0.01) return { precision: 5, minMove: 0.00001 }; return { precision: 8, minMove: 0.00000001 }; } function stFmtPrice(p) { if (p == null || isNaN(p)) return '—'; const a = Math.abs(p); if (a >= 1000) return p.toLocaleString('en-US', { maximumFractionDigits: 1 }); if (a >= 50) return p.toFixed(2); if (a >= 1) return p.toFixed(3); if (a >= 0.01) return p.toFixed(5); return p.toPrecision(4); } function stEma(closes, period) { const k = 2 / (period + 1); let e = closes[0]; return closes.map(c => (e = c * k + e * (1 - k))); } function stVwap(cs) { let pv = 0, vv = 0; return cs.map(c => { const t = (c.h + c.l + c.c) / 3; pv += t * c.v; vv += c.v; return vv ? pv / vv : c.c; }); } function stRsi(closes, period = 14) { const out = [50]; let ag = 0, al = 0; for (let i = 1; i < closes.length; i++) { const d = closes[i] - closes[i - 1]; const g = Math.max(0, d), l = Math.max(0, -d); if (i <= period) { ag += g / period; al += l / period; out.push(i === period ? 100 - 100 / (1 + ag / (al || 1e-9)) : 50); } else { ag = (ag * (period - 1) + g) / period; al = (al * (period - 1) + l) / period; out.push(100 - 100 / (1 + ag / (al || 1e-9))); } } return out; } function STProChart({ sym, orders = [], supportZones = [], resistanceZones = [], defaultTf = '15m', showRSI = true }) { const [tf, setTf] = React.useState(defaultTf); const [ohlc, setOhlc] = React.useState(null); /* crosshair readout */ const [meta, setMeta] = React.useState(null); /* last bar + ema values for legend */ const [err, setErr] = React.useState(null); const hostRef = React.useRef(null); const rsiRef = React.useRef(null); const chRef = React.useRef({}); /* charts + series + data */ /* ── build charts once per mount ── */ React.useEffect(() => { if (!window.LightweightCharts || !hostRef.current) { setErr('chart engine failed to load'); return; } const LW = window.LightweightCharts; const base = { layout: { background: { type: 'solid', color: 'transparent' }, textColor: ST.ink2, fontFamily: 'JetBrains Mono, monospace', fontSize: 12 }, grid: { vertLines: { color: ST.grid }, horzLines: { color: ST.grid } }, rightPriceScale: { borderColor: ST.border, scaleMargins: { top: 0.06, bottom: 0.18 } }, timeScale: { borderColor: ST.border, timeVisible: true, secondsVisible: false, rightOffset: 4, barSpacing: 16, minBarSpacing: 4, /* stability: no scrolling/zooming into empty void on either side (the "drifts left, blank on the right" bug), and the view stays put when the panel resizes (rail collapse) */ fixLeftEdge: true, fixRightEdge: true, lockVisibleTimeRangeOnResize: true, rightBarStaysOnScroll: true }, crosshair: { mode: LW.CrosshairMode.Normal, /* time label stays neutral; the PRICE label (right axis) is amber so the number you're hovering pops off the dark candles instead of blending into the black/white background — lightweight-charts auto-picks dark text on the amber fill (= yellow chip, black numbers) */ vertLine: { color: ST.ink3, width: 1, style: LW.LineStyle.Dashed, labelBackgroundColor: ST.panel }, horzLine: { color: ST.amber, width: 1, style: LW.LineStyle.Dashed, labelBackgroundColor: ST.amber }, }, autoSize: true, }; const chart = LW.createChart(hostRef.current, base); const candle = chart.addCandlestickSeries({ upColor: ST.green, downColor: ST.red, wickUpColor: ST.green, wickDownColor: ST.red, borderVisible: false, }); const volume = chart.addHistogramSeries({ priceScaleId: 'vol', priceFormat: { type: 'volume' }, lastValueVisible: false, priceLineVisible: false, }); chart.priceScale('vol').applyOptions({ scaleMargins: { top: 0.84, bottom: 0 } }); const mkLine = (color, width, style) => chart.addLineSeries({ color, lineWidth: width, lineStyle: style || 0, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false, }); const ema21 = mkLine(ST.amber, 2); const ema55 = mkLine(ST.blue, 2); const vwap = mkLine(ST.ink2, 1, 2); let rsiChart = null, rsiLine = null; if (showRSI && rsiRef.current) { rsiChart = LW.createChart(rsiRef.current, { ...base, rightPriceScale: { borderColor: ST.border, scaleMargins: { top: 0.12, bottom: 0.08 } }, timeScale: { ...base.timeScale, visible: false }, }); rsiLine = rsiChart.addLineSeries({ color: ST.amber, lineWidth: 1, priceLineVisible: false, lastValueVisible: true, priceFormat: { type: 'price', precision: 1, minMove: 0.1 }, }); rsiLine.createPriceLine({ price: 70, color: `${ST.red}80`, lineWidth: 1, lineStyle: LW.LineStyle.Dashed, title: '70' }); rsiLine.createPriceLine({ price: 30, color: `${ST.green}80`, lineWidth: 1, lineStyle: LW.LineStyle.Dashed, title: '30' }); /* keep the two panes locked together */ let guard = false; const link = (a, b) => a.timeScale().subscribeVisibleLogicalRangeChange(r => { if (guard || !r) return; guard = true; b.timeScale().setVisibleLogicalRange(r); guard = false; }); link(chart, rsiChart); link(rsiChart, chart); } /* Update the OHLC readout only when the hovered BAR changes — per-pixel setState re-rendered the toolbar ~60×/s and made the whole card flicker */ let lastBarT = undefined; chart.subscribeCrosshairMove(p => { const d = p && p.seriesData ? p.seriesData.get(candle) : null; const t = d ? p.time : null; if (t === lastBarT) return; lastBarT = t; setOhlc(d ? { o: d.open, h: d.high, l: d.low, c: d.close, t } : null); }); chRef.current = { LW, chart, candle, volume, ema21, ema55, vwap, rsiChart, rsiLine, priceLines: [] }; return () => { chart.remove(); if (rsiChart) rsiChart.remove(); chRef.current = {}; }; }, [showRSI]); /* ── data load (tf / sym change + 30s refresh) ── */ const load = React.useCallback((quiet) => { const R = chRef.current; if (!R.chart) return; const preset = ST_TF[tf] || ST_TF['15m']; const limit = Math.min(1000, preset.bars + 120); // +warmup for EMA55 ST_fetch(`/market/candles?symbol=${sym}&interval=${preset.iv}&limit=${limit}`) .then(d => { const cs = (d.candles || []).map(c => ({ time: Math.floor(c.open_time / 1000), o: c.open, h: c.high, l: c.low, c: c.close, v: c.volume, })); if (!cs.length) { setErr('no data yet — syncing from Bybit, retry in a few sec'); return; } setErr(null); const closes = cs.map(c => c.c); const e21 = stEma(closes, 21), e55 = stEma(closes, 55), vw = stVwap(cs), rs = stRsi(closes); const stay = quiet ? R.chart.timeScale().getVisibleLogicalRange() : null; const pinned = !quiet || (R.chart.timeScale().scrollPosition() > -3); const prec = stPrecision(closes[closes.length - 1]); R.candle.applyOptions({ priceFormat: { type: 'price', ...prec } }); R.candle.setData(cs.map(c => ({ time: c.time, open: c.o, high: c.h, low: c.l, close: c.c }))); R.volume.setData(cs.map(c => ({ time: c.time, value: c.v, color: (c.c >= c.o ? ST.green : ST.red) + '4D' }))); R.ema21.setData(cs.map((c, i) => ({ time: c.time, value: e21[i] }))); R.ema55.setData(cs.map((c, i) => ({ time: c.time, value: e55[i] }))); R.vwap.setData(cs.map((c, i) => ({ time: c.time, value: vw[i] }))); if (R.rsiLine) R.rsiLine.setData(cs.map((c, i) => ({ time: c.time, value: rs[i] }))); if (!quiet) { /* first paint: show exactly the preset's window — big readable candles */ R.chart.timeScale().setVisibleLogicalRange( { from: Math.max(0, cs.length - preset.bars), to: cs.length + 2 }); } else if (!pinned && stay) { R.chart.timeScale().setVisibleLogicalRange(stay); } const last = cs[cs.length - 1]; setMeta({ last: last.c, up: last.c >= last.o, e21: e21[e21.length - 1], e55: e55[e55.length - 1], vw: vw[vw.length - 1], rsi: rs[rs.length - 1] }); }) .catch(e => setErr(String(e.message || e))); }, [sym, tf]); React.useEffect(() => { load(false); }, [load]); React.useEffect(() => { const t = setInterval(() => load(true), 30000); return () => clearInterval(t); }, [load]); /* ── premium watermark: faint pair name behind the candles ── */ React.useEffect(() => { const R = chRef.current; if (!R.chart) return; R.chart.applyOptions({ watermark: { visible: true, text: `${sym}/USDT`, color: 'rgba(255,255,255,0.045)', fontSize: 46, fontFamily: 'Geist, sans-serif', horzAlign: 'center', vertAlign: 'center', }}); }, [sym]); /* ── overlays: order lines + S/R zone lines ── */ React.useEffect(() => { const R = chRef.current; if (!R.candle) return; R.priceLines.forEach(pl => { try { R.candle.removePriceLine(pl); } catch (e) {} }); R.priceLines = []; const add = (price, color, title, style) => { if (price == null || isNaN(price)) return; R.priceLines.push(R.candle.createPriceLine({ price, color, lineWidth: 1, lineStyle: style != null ? style : 2, title, axisLabelVisible: true, })); }; orders.forEach(o => { const col = o.verdict === 'GOOD' ? ST.green : o.verdict === 'AVOID' ? ST.red : ST.amber; add(o.price, col, `BUY ${o.qty != null ? o.qty : ''}`); }); supportZones.forEach(z => { add(z.lo, ST.green, 'SUP', 3); add(z.hi, ST.green, '', 3); }); resistanceZones.forEach(z => { add(z.lo, ST.red, '', 3); add(z.hi, ST.red, 'RES', 3); }); }, [orders, supportZones, resistanceZones, sym, tf]); /* ── UI ── */ const TfBtn = t => ( ); const Leg = ({ color, label, value, dash }) => (
{label} {value}
); return (
{/* toolbar */}
{Object.keys(ST_TF).map(TfBtn)}
{meta && } {meta && } {meta && } {/* fixed-width right block — swapping last↔OHLC must never reflow the toolbar. The readout sits in a constant-width, right-aligned slot so hovering (which swaps the short "last" line for the wider O/H/L/C line) can't change the wrap point and nudge the chart down. */}
{ohlc ? ( O {stFmtPrice(ohlc.o)}{' '} H {stFmtPrice(ohlc.h)}{' '} L {stFmtPrice(ohlc.l)}{' '} C = ohlc.o ? ST.green : ST.red }}>{stFmtPrice(ohlc.c)} ) : meta ? ( last {stFmtPrice(meta.last)} {showRSI && · RSI 70 ? ST.red : meta.rsi < 30 ? ST.green : ST.ink }}>{meta.rsi.toFixed(1)}} ) : null}
scroll = zoom · drag = pan
{/* price + volume pane */}
{err && (
{err}
)}
{/* RSI pane */} {showRSI && (
RSI · 14
)}
); } window.STProChart = STProChart;