UNPKG

trade-data-generator

Version:

Generate realistic real-time market data for trading UIs — equity, forex and crypto. Emits tick, candle, orderbook and market status events via EventEmitter.

660 lines (584 loc) 22.6 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>trade-data-generator — Test Terminal</title> <script src="https://unpkg.com/lightweight-charts@3.8.0/dist/lightweight-charts.standalone.production.js"></script> <script src="/socket.io/socket.io.js"></script> <link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet" /> <style> :root { --bg: #040608; --surface: #0c0f14; --border: #1a1f2a; --text: #e8edf5; --muted: #5a6478; --accent: #00d4aa; --red: #f6465d; --green: #00d4aa; --blue: #0098ff; --mono: 'DM Mono', monospace; --sans: 'DM Sans', sans-serif; } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } html, body { height: 100%; overflow: hidden; } body { background: var(--bg); color: var(--text); font-family: var(--sans); display: flex; flex-direction: column; height: 100vh; } .nav { height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 1rem; border-bottom: 1px solid var(--border); flex-shrink: 0; gap: 1rem; } .nav-logo { font-family: var(--mono); font-size: 0.8rem; color: var(--accent); white-space: nowrap; } .nav-pairs { display: flex; gap: 0.25rem; flex: 1; overflow-x: auto; } .pair-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.3rem 0.8rem; background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--muted); cursor: pointer; font-family: var(--mono); font-size: 0.75rem; white-space: nowrap; transition: all 0.15s; } .pair-btn:hover { border-color: var(--muted); color: var(--text); } .pair-btn.active { background: rgba(0,212,170,0.08); border-color: var(--accent); color: var(--text); } .pair-btn .p-chg.up { color: var(--green); font-size: 0.68rem; } .pair-btn .p-chg.down { color: var(--red); font-size: 0.68rem; } .nav-source { display: flex; align-items: center; gap: 0.4rem; padding: 0.25rem 0.75rem; border-radius: 100px; font-family: var(--mono); font-size: 0.7rem; border: 1px solid; white-space: nowrap; } .nav-source.simulation { background: rgba(0,212,170,0.08); border-color: rgba(0,212,170,0.3); color: var(--accent); } .nav-source.binance { background: rgba(0,152,255,0.08); border-color: rgba(0,152,255,0.3); color: var(--blue); } .source-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; animation: pulse 1.5s ease-in-out infinite; } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } .ticker { display: flex; align-items: center; gap: 2rem; padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap; } .ticker-main { display: flex; align-items: baseline; gap: 0.75rem; } .ticker-symbol { font-family: var(--mono); font-size: 0.85rem; color: var(--muted); } .ticker-price { font-family: var(--mono); font-size: 1.6rem; font-weight: 500; letter-spacing: -0.02em; } .ticker-chg { font-family: var(--mono); font-size: 0.8rem; font-weight: 600; padding: 0.15rem 0.4rem; border-radius: 4px; } .ticker-chg.up { color: var(--green); background: rgba(0,212,170,0.1); } .ticker-chg.down { color: var(--red); background: rgba(246,70,93,0.1); } .ticker-stat { display: flex; flex-direction: column; gap: 0.1rem; } .ticker-stat-label { font-size: 0.65rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; } .ticker-stat-value { font-family: var(--mono); font-size: 0.8rem; } .interval-bar { display: flex; align-items: center; gap: 0.25rem; padding: 0.4rem 1rem; border-bottom: 1px solid var(--border); flex-shrink: 0; } .int-btn { padding: 0.2rem 0.6rem; font-family: var(--mono); font-size: 0.72rem; border-radius: 4px; cursor: pointer; border: none; background: none; color: var(--muted); transition: all 0.15s; } .int-btn:hover { color: var(--text); } .int-btn.active { background: rgba(0,212,170,0.1); color: var(--accent); } .main { display: grid; grid-template-columns: 1fr 240px 220px; flex: 1; min-height: 0; overflow: hidden; } .chart-wrap { display: flex; flex-direction: column; border-right: 1px solid var(--border); min-height: 0; } #chart { flex: 1; min-height: 0; } .ob-wrap { display: flex; flex-direction: column; border-right: 1px solid var(--border); overflow: hidden; } .panel-title { padding: 0.5rem 0.75rem; font-size: 0.68rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid var(--border); flex-shrink: 0; display: flex; justify-content: space-between; } .ob-labels { display: grid; grid-template-columns: 1fr 1fr 1fr; padding: 0.3rem 0.75rem; font-size: 0.62rem; color: var(--muted); border-bottom: 1px solid var(--border); flex-shrink: 0; } .ob-labels span:last-child { text-align: right; } .ob-labels span:nth-child(2) { text-align: center; } .ob-asks { flex: 1; overflow: hidden; display: flex; flex-direction: column-reverse; } .ob-mid { padding: 0.3rem 0.75rem; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); font-family: var(--mono); font-size: 0.85rem; font-weight: 600; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; } .ob-mid .spread { font-size: 0.62rem; color: var(--muted); } .ob-bids { flex: 1; overflow: hidden; } .ob-row { display: grid; grid-template-columns: 1fr 1fr 1fr; padding: 0.18rem 0.75rem; font-family: var(--mono); font-size: 0.7rem; position: relative; cursor: default; } .ob-row:hover { background: rgba(255,255,255,0.02); } .ob-bar { position: absolute; top: 0; right: 0; height: 100%; opacity: 0.07; } .ob-bar.ask { background: var(--red); } .ob-bar.bid { background: var(--green); } .ob-row .p { font-weight: 500; } .ob-row .s { text-align: center; color: var(--muted); } .ob-row .t { text-align: right; color: var(--muted); } .ob-row.ask .p { color: var(--red); } .ob-row.bid .p { color: var(--green); } .right-wrap { display: flex; flex-direction: column; overflow: hidden; } .trades-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; border-bottom: 1px solid var(--border); } .trade-row { display: grid; grid-template-columns: 1fr 1fr 1fr; padding: 0.2rem 0.75rem; font-family: var(--mono); font-size: 0.68rem; } .trade-row .tp { font-weight: 500; } .trade-row .ts { text-align: center; color: var(--muted); } .trade-row .tt { text-align: right; color: var(--muted); } .trade-row.buy .tp { color: var(--green); } .trade-row.sell .tp { color: var(--red); } .trades-list { flex: 1; overflow: hidden; display: flex; flex-direction: column; } .stats-wrap { padding: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem; } .stat-row { display: flex; justify-content: space-between; align-items: center; padding: 0.3rem 0; border-bottom: 1px solid var(--border); font-size: 0.75rem; } .stat-row:last-child { border-bottom: none; } .stat-row .sl { color: var(--muted); } .stat-row .sv { font-family: var(--mono); } .bottom { height: 28px; display: flex; align-items: center; padding: 0 1rem; gap: 1.5rem; border-top: 1px solid var(--border); background: var(--surface); flex-shrink: 0; } .bottom-item { font-family: var(--mono); font-size: 0.65rem; color: var(--muted); display: flex; gap: 0.3rem; } .bottom-item span { color: var(--text); } .bottom-right { margin-left: auto; font-family: var(--mono); font-size: 0.65rem; color: var(--muted); } .overlay { position: fixed; inset: 0; background: rgba(4,6,8,0.9); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; z-index: 100; font-family: var(--mono); color: var(--muted); font-size: 0.85rem; } .overlay.hidden { display: none; } .spinner { width: 24px; height: 24px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } ::-webkit-scrollbar { width: 3px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } </style> </head> <body> <div class="overlay" id="overlay"> <div class="spinner"></div> <div id="overlay-msg">Connecting to server...</div> </div> <div class="nav"> <div class="nav-logo">trade-data-generator</div> <div class="nav-pairs" id="pair-bar"></div> <div class="nav-source simulation" id="source-badge"> <div class="source-dot"></div> <span id="source-label">simulation</span> </div> </div> <div class="ticker"> <div class="ticker-main"> <span class="ticker-symbol" id="t-symbol"></span> <span class="ticker-price" id="t-price"></span> <span class="ticker-chg up" id="t-chg"></span> </div> <div class="ticker-stat"> <span class="ticker-stat-label">24h High</span> <span class="ticker-stat-value" id="t-high"></span> </div> <div class="ticker-stat"> <span class="ticker-stat-label">24h Low</span> <span class="ticker-stat-value" id="t-low"></span> </div> <div class="ticker-stat"> <span class="ticker-stat-label">Volume</span> <span class="ticker-stat-value" id="t-vol"></span> </div> <div class="ticker-stat"> <span class="ticker-stat-label">Bid</span> <span class="ticker-stat-value" style="color:var(--green)" id="t-bid"></span> </div> <div class="ticker-stat"> <span class="ticker-stat-label">Ask</span> <span class="ticker-stat-value" style="color:var(--red)" id="t-ask"></span> </div> <div class="ticker-stat"> <span class="ticker-stat-label">Spread</span> <span class="ticker-stat-value" id="t-spread"></span> </div> </div> <div class="interval-bar"> <button class="int-btn active" onclick="setInterval_('1m')">1m</button> <button class="int-btn" onclick="setInterval_('5m')">5m</button> <button class="int-btn" onclick="setInterval_('15m')">15m</button> <button class="int-btn" onclick="setInterval_('1h')">1h</button> </div> <div class="main"> <div class="chart-wrap"> <div id="chart"></div> </div> <div class="ob-wrap"> <div class="panel-title"> <span>Order Book</span> <span>10 levels</span> </div> <div class="ob-labels"> <span>Price</span><span>Size</span><span>Total</span> </div> <div class="ob-asks" id="ob-asks"></div> <div class="ob-mid"> <span id="ob-mid-price" class="up"></span> <span class="spread" id="ob-spread">spread —</span> </div> <div class="ob-labels"> <span>Price</span><span>Size</span><span>Total</span> </div> <div class="ob-bids" id="ob-bids"></div> </div> <div class="right-wrap"> <div class="trades-wrap"> <div class="panel-title"><span>Recent Trades</span></div> <div class="ob-labels"> <span>Price</span><span>Size</span><span>Time</span> </div> <div class="trades-list" id="trades-list"></div> </div> <div class="stats-wrap"> <div class="panel-title" style="padding:0 0 0.5rem;border:none;"> <span>Session Stats</span> </div> <div class="stat-row"><span class="sl">Source</span><span class="sv" id="st-source"></span></div> <div class="stat-row"><span class="sl">Symbol</span><span class="sv" id="st-symbol"></span></div> <div class="stat-row"><span class="sl">Ticks</span><span class="sv" id="st-ticks">0</span></div> <div class="stat-row"><span class="sl">Candles</span><span class="sv" id="st-candles">0</span></div> <div class="stat-row"><span class="sl">Depth updates</span><span class="sv" id="st-depth">0</span></div> <div class="stat-row"><span class="sl">Last candle</span><span class="sv" id="st-last-candle"></span></div> </div> </div> </div> <div class="bottom"> <div class="bottom-item">Type <span>Crypto</span></div> <div class="bottom-item">Status <span style="color:var(--green)">Live</span></div> <div class="bottom-item">Interval <span id="bot-interval">1m</span></div> <div class="bottom-item">Ticks <span id="bot-ticks">0</span></div> <div class="bottom-right">trade-data-generator test terminal</div> </div> <script> const socket = io() let currentPair = null let currentInt = '1m' let chart = null let candleSeries= null let trades = [] let tickCount = 0, candleCount = 0, depthCount = 0 const fmt = (v, p = 2) => { if (v == null) return '—' return parseFloat(v).toLocaleString('en-US', { minimumFractionDigits: p, maximumFractionDigits: p }) } const fmtTime = (ts) => new Date(ts).toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) const getP = (sym) => { if (!sym) return 2 if (sym.includes('JPY')) return 3 if (sym.includes('EUR') || sym.includes('GBP')) return 5 if (sym.includes('SOL') || sym.includes('XRP')) return 3 return 2 } function initChart() { const el = document.getElementById('chart') if (chart) { chart.remove(); chart = null } chart = LightweightCharts.createChart(el, { layout: { background: { color: '#040608' }, textColor: '#5a6478' }, grid: { vertLines: { color: '#0d1117' }, horzLines: { color: '#0d1117' } }, crosshair: { mode: LightweightCharts.CrosshairMode.Normal }, rightPriceScale: { borderColor: '#1a1f2a' }, timeScale: { borderColor: '#1a1f2a', timeVisible: true, secondsVisible: false }, width: el.clientWidth, height: el.clientHeight, }) candleSeries = chart.addCandlestickSeries({ upColor: '#00d4aa', downColor: '#f6465d', borderUpColor: '#00d4aa', borderDownColor: '#f6465d', wickUpColor: '#00d4aa', wickDownColor: '#f6465d', }) window.addEventListener('resize', () => { if (chart) chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }) }) } function buildPairBar(pairs) { const bar = document.getElementById('pair-bar') bar.innerHTML = '' pairs.forEach(sym => { const btn = document.createElement('button') btn.className = 'pair-btn' + (sym === currentPair ? ' active' : '') btn.id = `pair-${sym.replace('/', '-')}` btn.innerHTML = ` <span>${sym}</span> <span id="pp-${sym.replace('/', '-')}">—</span> <span class="p-chg up" id="pc-${sym.replace('/', '-')}">—</span> ` btn.onclick = () => switchPair(sym) bar.appendChild(btn) }) } function updatePairTab(sym, price, changePct) { const p = getP(sym) const key = sym.replace('/', '-') const pEl = document.getElementById(`pp-${key}`) const cEl = document.getElementById(`pc-${key}`) if (pEl) pEl.textContent = fmt(price, p) if (cEl) { cEl.textContent = (changePct >= 0 ? '+' : '') + changePct?.toFixed(2) + '%' cEl.className = 'p-chg ' + (changePct >= 0 ? 'up' : 'down') } } function switchPair(sym) { currentPair = sym tickCount = candleCount = depthCount = 0 trades = [] document.querySelectorAll('.pair-btn').forEach(b => b.classList.remove('active')) const tab = document.getElementById(`pair-${sym.replace('/', '-')}`) if (tab) tab.classList.add('active') document.getElementById('st-symbol').textContent = sym document.getElementById('t-symbol').textContent = sym document.getElementById('trades-list').innerHTML = '' document.getElementById('ob-asks').innerHTML = '' document.getElementById('ob-bids').innerHTML = '' candleSeries?.setData([]) socket.emit('subscribe', { symbol: sym }) } function setInterval_(int) { currentInt = int document.getElementById('bot-interval').textContent = int document.querySelectorAll('.int-btn').forEach(b => { b.classList.toggle('active', b.textContent === int) }) candleSeries?.setData([]) } function updateTicker(data) { const p = getP(data.symbol) const up = data.changePct >= 0 document.getElementById('t-price').textContent = fmt(data.price, p) document.getElementById('t-high').textContent = fmt(data.high24h, p) document.getElementById('t-low').textContent = fmt(data.low24h, p) document.getElementById('t-vol').textContent = fmt(data.volume, 2) document.getElementById('t-bid').textContent = fmt(data.bid, p) document.getElementById('t-ask').textContent = fmt(data.ask, p) document.getElementById('t-spread').textContent = fmt(data.spread, p) const chgEl = document.getElementById('t-chg') chgEl.textContent = (up ? '+' : '') + (data.changePct?.toFixed(2) ?? '0.00') + '%' chgEl.className = 'ticker-chg ' + (up ? 'up' : 'down') const midEl = document.getElementById('ob-mid-price') midEl.textContent = fmt(data.price, p) midEl.className = up ? 'up' : 'down' tickCount++ document.getElementById('st-ticks').textContent = tickCount.toLocaleString() document.getElementById('bot-ticks').textContent = tickCount.toLocaleString() const side = data.price >= (data.previous ?? data.price) ? 'buy' : 'sell' const tradeVol = data.source === 'binance' ? (Math.random() * 2).toFixed(4) : data.volume trades.unshift({ price: data.price, volume: tradeVol, side, time: Date.now() }) if (trades.length > 30) trades.pop() renderTrades(p) } function renderTrades(p) { const list = document.getElementById('trades-list') list.innerHTML = '' trades.forEach(t => { const row = document.createElement('div') row.className = `trade-row ${t.side}` row.innerHTML = ` <span class="tp">${fmt(t.price, p)}</span> <span class="ts">${fmt(t.volume, 4)}</span> <span class="tt">${fmtTime(t.time)}</span> ` list.appendChild(row) }) } function renderOrderBook(data) { const p = getP(data.symbol) const asks = data.asks ?? [] const bids = data.bids ?? [] const maxVol = Math.max(...bids.map(b=>b.volume), ...asks.map(a=>a.volume), 1) const asksEl = document.getElementById('ob-asks') const bidsEl = document.getElementById('ob-bids') asksEl.innerHTML = '' ;[...asks].reverse().forEach(a => { const pct = (a.volume / maxVol * 100).toFixed(0) const row = document.createElement('div') row.className = 'ob-row ask' row.innerHTML = ` <div class="ob-bar ask" style="width:${pct}%"></div> <span class="p">${fmt(a.price, p)}</span> <span class="s">${fmt(a.volume, 4)}</span> <span class="t">${fmt(a.price * a.volume, 0)}</span> ` asksEl.appendChild(row) }) bidsEl.innerHTML = '' bids.forEach(b => { const pct = (b.volume / maxVol * 100).toFixed(0) const row = document.createElement('div') row.className = 'ob-row bid' row.innerHTML = ` <div class="ob-bar bid" style="width:${pct}%"></div> <span class="p">${fmt(b.price, p)}</span> <span class="s">${fmt(b.volume, 4)}</span> <span class="t">${fmt(b.price * b.volume, 0)}</span> ` bidsEl.appendChild(row) }) if (asks.length && bids.length) { document.getElementById('ob-spread').textContent = `spread ${fmt(asks[0].price - bids[0].price, p)}` } depthCount++ document.getElementById('st-depth').textContent = depthCount.toLocaleString() } // ── Socket ─────────────────────────────── // socket.on('connect', () => { document.getElementById('overlay-msg').textContent = 'Connected — waiting for config...' }) socket.on('config', (cfg) => { const source = cfg.source const badge = document.getElementById('source-badge') badge.className = `nav-source ${source}` document.getElementById('source-label').textContent = source document.getElementById('st-source').textContent = source buildPairBar(cfg.pairs) if (cfg.pairs.length > 0) switchPair(cfg.pairs[0]) document.getElementById('overlay').classList.add('hidden') }) socket.on('snapshot', (state) => { if (!state) return if (state.candles?.[currentInt]?.length > 0) { candleSeries?.setData(state.candles[currentInt].map(c => ({ time: c.time ?? Math.floor(c.openTime / 1000), open: c.open, high: c.high, low: c.low, close: c.close, }))) } }) socket.on('tick', (data) => { updatePairTab(data.symbol, data.price, data.changePct) if (data.symbol !== currentPair) return updateTicker(data) }) socket.on('mini_tick', (data) => { updatePairTab(data.symbol, data.price, data.changePct) }) socket.on('candle', (data) => { if (data.symbol !== currentPair || data.interval !== currentInt) return console.log('[candle] openTime raw:', data.openTime, '→ seconds:', Math.floor(data.openTime / 1000)) candleSeries?.update({ time: Math.floor(data.openTime / 1000), open: data.open, high: data.high, low: data.low, close: data.close, }) if (data.closed) { candleCount++ document.getElementById('st-candles').textContent = candleCount document.getElementById('st-last-candle').textContent = `O:${data.open} H:${data.high} L:${data.low} C:${data.close}` } }) socket.on('depth', (data) => { if (data.symbol !== currentPair) return renderOrderBook(data) }) socket.on('disconnect', () => { document.getElementById('overlay').classList.remove('hidden') document.getElementById('overlay-msg').textContent = 'Disconnected — reconnecting...' }) initChart() </script> </body> </html>