// Live instrument detail screen — variant #3 (dual hero + dual-column depth) // Tabs: Depth · Chart (candles) · Spec. Fixed SELL/BUY across all tabs. // 3-part price (base · significant pips · fractional), top-of-book sized function DPxLive({ value, inst, size = 30, color, weight = 700 }) { const { base, pips, frac } = splitPip(value, inst.digits, inst.pipDigits); return ( // A 3-part price is three ADJACENT spans, which is exactly the shape // RTL reorders: 1.08|57|4 renders as 4|57|1.08 — an unreadable price, // not a cosmetic difference. Pinning the wrapper to LTR keeps the // parts in order; the surrounding row still mirrors normally (ID5). {base} {pips} {frac && {frac}} ); } const DET_CAT_COLOR = { FX: '#3B82F6', Metals: '#E0A21A', Energy: '#E2563B', Indices: '#8B5CF6', Crypto: '#F7931A' }; function detVol(v) { return v.toFixed(2); } const CHART_TFS = ['1m', '5m', '15m', '1H', '4H', '1D']; // Timeframe codes are the values the chart, the candle request and the cell // state all compare against — they stay canonical. Only the chip's LABEL is // localized, looked up at render time (a t() call out here would run before // initI18n() resolves). MT platforms localize these, so the regional teams get // the choice; the en values are the codes themselves. const TF_LABEL_KEY = { '1m': 'chart.tf.m1', '5m': 'chart.tf.m5', '15m': 'chart.tf.m15', '1H': 'chart.tf.h1', '4H': 'chart.tf.h4', '1D': 'chart.tf.d1', }; // Real OHLC from the candle store (ts_candles via /api/instruments/candles); // 1m aggregated live from the base feed, higher TFs rolled up server-side. function CandleChart({ inst, ps, theme }) { const [tf, setTf] = React.useState('1m'); const [candles, setCandles] = React.useState(null); // null = loading React.useEffect(() => { let alive = true; const load = () => apiCandles(inst, tf, 60) .then(res => { if (alive) setCandles(res.candles || []); }) .catch(() => { if (alive) setCandles([]); }); setCandles(null); load(); const id = setInterval(load, 5000); // forming candle updates live return () => { alive = false; clearInterval(id); }; }, [inst.sym, tf]); const tfBar = (
{CHART_TFS.map(code => (
setTf(code)} style={{ padding: '6px 12px', borderRadius: 8, fontSize: 12.5, fontWeight: 600, color: code === tf ? theme.text : theme.textSec, background: code === tf ? theme.bgCard : 'transparent', cursor: 'pointer' }}>{t(TF_LABEL_KEY[code])}
))}
); if (!candles || candles.length < 2) { return (
{candles === null ? t('chart.loading') : t('chart.collectingHint')}
{tfBar}
); } const W = 372, H = 232, padR = 52; const plotW = W - padR; const his = candles.map(c => c.h), los = candles.map(c => c.l); const max = Math.max(...his), min = Math.min(...los), rng = (max - min) || 1; const y = (v) => H - ((v - min) / rng) * (H - 20) - 10; const slot = plotW / candles.length; const bodyW = slot * 0.62; const last = candles[candles.length - 1]; const lastUp = last.c >= last.o; const lastY = y(ps.bid); return (
{/* gridlines + right axis labels */} {[0, 0.25, 0.5, 0.75, 1].map(f => { const v = max - f * rng; const yy = y(v); return ( {fmtPrice(v, inst.digits)} ); })} {/* candles */} {candles.map((cd, i) => { const cx = i * slot + slot / 2; const up = cd.c >= cd.o; const col = up ? theme.buy : theme.sell; const bodyTop = y(Math.max(cd.o, cd.c)); const bodyBot = y(Math.min(cd.o, cd.c)); return ( ); })} {/* current price line */} {fmtPrice(ps.bid, inst.digits)} {tfBar}
); } // Real order-book depth: the monolith's book mirror via /api/instruments/ // depth, marked with the account group's markup. Polled while the tab is open. function DepthCols({ inst, ps, theme }) { const [book, setBook] = React.useState(null); React.useEffect(() => { let alive = true; const load = () => apiDepth(inst) .then(res => { if (alive) setBook(res); }) .catch(() => { if (alive) setBook({ bids: [], asks: [] }); }); load(); const id = setInterval(load, 2000); return () => { alive = false; clearInterval(id); }; }, [inst.sym]); if (!book || (!book.bids.length && !book.asks.length)) { return (
{book === null ? t('book.loading') : t('book.noDepth')}
); } const bids = book.bids, asks = book.asks; const max = Math.max(...bids.map(l => l.vol), ...asks.map(l => l.vol), 0.01); const buyBar = theme.buy + '33', sellBar = theme.sell + '33'; const col = (lvl, isAsk) => { const w = (lvl.vol / max) * 100; const c = isAsk ? theme.sell : theme.buy; const bar = isAsk ? sellBar : buyBar; return ( // Physical left/right throughout, deliberately: the ladder is pinned LTR // by the dir="ltr" wrapper below, so "left" and "right" mean what they // say here and logical properties would flip back out of the pin.
{isAsk ? ( <> {fmtPrice(lvl.price, inst.digits)} {detVol(lvl.vol)} ) : ( <> {detVol(lvl.vol)} {fmtPrice(lvl.price, inst.digits)} )}
); }; return ( // ——— the depth ladder does NOT mirror (docs/i18n.md ID5/IO3) ——— // Bids on the left growing right, asks on the right growing left, prices // meeting at the spread: one shape every desk reads the same way, and the // bars are a spatial encoding of size rather than decoration. Mirroring // would swap the two sides and flip every bar away from its price. Pinned // LTR here so the whole subtree keeps physical geometry; the Arabic column // headers still shape right-to-left inside their own cells, because // Arabic is strong-RTL within its own run.
{t('book.col.bids')}
{t('book.col.asks')}
{bids.map(l => col(l, false))}
{asks.map(l => col(l, true))}
); } // Real contract specification (M6): instrument master + account leverage + // group swap points from the Backoffice. Absent values render as an em dash — // nothing is invented. function SpecSheet({ inst, theme }) { const [spec, setSpec] = React.useState(null); React.useEffect(() => { let alive = true; apiSpec(inst).then(s => { if (alive) setSpec(s); }).catch(() => { if (alive) setSpec({}); }); return () => { alive = false; }; }, [inst.sym]); if (spec === null) { return
{t('spec.loading')}
; } const dash = '—'; const base = inst.backend.includes('/') ? inst.backend.split('/')[0] : inst.sym; const contract = spec.contractSize ? spec.contractSize.toLocaleString('en-US') + ' ' + base : dash; const marginCalc = spec.marginStrategy === 'without-leverage' ? t('spec.marginCalc.fullNotionalNoLeverage') : t('spec.marginCalc.accountLeverage'); const leverage = spec.leverage ? '1:' + spec.leverage : dash; const marginPct = spec.marginPct ? (parseFloat(spec.marginPct) * 100).toFixed(0) + '%' : dash; // The L/S swap legs are one key each rather than a translated letter glued to // a number — the letter and the value have to stay one phrase to reorder. const swaps = (spec.swapLong != null || spec.swapShort != null) ? {t('spec.swap.long', { value: spec.swapLong != null ? spec.swapLong : dash })} {t('spec.swap.short', { value: spec.swapShort != null ? spec.swapShort : dash })} : dash; const rows = [ [t('spec.marginCalc.label'), marginCalc], [t('spec.accountLeverage'), leverage], [t('spec.marginPct'), marginPct], [t('spec.contractSizeLot'), contract], [t('spec.pointSize'), spec.tickSize ? spec.tickSize.toFixed(inst.digits) : dash], // Three numbers joined by neutral separators, and the ORDER carries the // whole meaning — min, step, max. Un-isolated under RTL the separators // resolve RTL (UBA N1: a number acts as R for the neutrals beside it) and // the run reverses to "100.00 / 0.01 / 0.01" — min and max swapped. The // value cell cannot be pinned LTR instead: other rows in the same column // hold translated words. Same class as the chart grid picker's "R×C". [t('spec.minStepMaxLots'), spec.minVol ? bidiIsolate(`${spec.minVol.toFixed(2)} / ${spec.volStep.toFixed(2)} / ${spec.maxVol.toFixed(2)}`) : dash], [t('spec.swapRatesPoints'), swaps], [t('spec.group'), spec.group || dash], [t('spec.tradingSession.label'), t('spec.tradingSession.value')], ]; return (
{t('spec.title')}
{rows.map(([label, value], i) => (
{label}
{value}
))}
{t('spec.footer.short')}
); } function InstrumentDetail({ inst, priceState, theme, onClose, onBuy, onSell, isFav, onToggleFav }) { const [tab, setTab] = React.useState('Chart'); const [entered, setEntered] = React.useState(false); React.useEffect(() => { const r = requestAnimationFrame(() => setEntered(true)); return () => cancelAnimationFrame(r); }, []); const ps = priceState; const pct = inst.pctDay; // live: api.jsx recomputes vs dayOpen on every tick const up = pct >= 0; const c = DET_CAT_COLOR[inst.cat] || theme.accent; return (
{/* Top bar */}
{inst.sym.length === 6 && inst.sym.match(/^[A-Z]{6}$/) ? inst.sym.slice(0, 3) + '/' + inst.sym.slice(3) : inst.sym}
{inst.name}
{/* Hero — dual BID/ASK blocks + spread + stats */}
{t('detail.hero.bidSell')}
{inst.spread.toFixed(1)}
{t('detail.hero.askBuy')}
0 ? fmtPrice(inst.dayHigh, inst.digits) : '—'} theme={theme}/> 0 ? fmtPrice(inst.dayLow, inst.digits) : '—'} theme={theme}/>
{/* Tabs */}
{/* The first element stays the canonical English tab id — it is the `tab` state and every branch below compares against it. Only the second, the label, is localized. (The map parameter can no longer be named `t`: that is the i18n function.) */} {[['Chart', 'detail.tab.chart'], ['Depth', 'detail.tab.depth'], ['Spec', 'detail.tab.spec']].map(([v, key]) => (
setTab(v)} style={{ flex: 1, textAlign: 'center', padding: '11px 0 12px', fontSize: 13.5, fontWeight: 600, color: v === tab ? theme.text : theme.textSec, position: 'relative', cursor: 'pointer' }}> {t(key)} {v === tab &&
}
))}
{/* Tab content */}
{tab === 'Depth' && } {tab === 'Chart' && } {tab === 'Spec' && }
{/* Fixed SELL / BUY */}
{t('common.side.sell')}
{t('common.side.buy')}
); } function DetStat({ label, value, color, theme }) { return (
{label}
{value}
); } Object.assign(window, { InstrumentDetail });