Spaces:
Running
Running
| // Trading Terminal Script - Mock microstructure with algorithmic bots | |
| // Light mode with primary green (#22c55e) and secondary red (#ef4444) | |
| (function () { | |
| const fmtUSD = (n) => { | |
| if (!isFinite(n)) return "-"; | |
| return n.toLocaleString(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 2 }); | |
| }; | |
| const fmtNum = (n, d = 4) => (isFinite(n) ? n.toFixed(d) : "-"); | |
| const fmtSize = (n) => isFinite(n) ? n.toFixed(4) : "-"; | |
| const nowTs = () => new Date().toLocaleTimeString(); | |
| // State | |
| const state = { | |
| symbol: "BTCUSD", | |
| exchange: "BINANCE", | |
| lastPrice: 50000, | |
| dayChange: 0, | |
| dayOpen: 50000, | |
| spread: 1.0, | |
| bids: [], | |
| asks: [], | |
| tape: [], | |
| bots: [], | |
| pausedAll: false, | |
| latency: 2, // ms | |
| portfolio: { | |
| cash: 1_000_000, | |
| marginUsed: 0, | |
| positions: { | |
| BTCUSD: { qty: 0, avg: 0 }, | |
| ETHUSD: { qty: 0, avg: 0 }, | |
| SOLUSD: { qty: 0, avg: 0 }, | |
| }, | |
| }, | |
| }; | |
| // Initialize order book levels | |
| function initBook() { | |
| const mid = state.lastPrice; | |
| const rows = 24; // rows per side | |
| const step = 0.5; | |
| const bids = []; | |
| const asks = []; | |
| let bid = mid - rows * step; | |
| for (let i = 0; i < rows; i++, bid += step) { | |
| bids.push({ price: +(bid).toFixed(1), size: +(Math.random() * 2 + 0.1).toFixed(4) }); | |
| } | |
| let ask = mid + step; | |
| for (let i = 0; i < rows; i++, ask += step) { | |
| asks.push({ price: +(ask).toFixed(1), size: +(Math.random() * 2 + 0.1).toFixed(4) }); | |
| } | |
| state.bids = bids; | |
| state.asks = asks; | |
| } | |
| // Normalize book size to max | |
| function normalizeBook(max = 24) { | |
| function compress(levels) { | |
| if (levels.length <= max) return levels; | |
| const drop = levels.length - max; | |
| return levels.slice(drop); | |
| } | |
| state.bids = compress(state.bids); | |
| state.asks = compress(state.asks); | |
| } | |
| // Update microstructure: small random walk and depth changes | |
| function tickMicrostructure() { | |
| const drift = (Math.random() - 0.5) * 0.4; // mild drift | |
| state.lastPrice += drift; | |
| // Top of book drift | |
| if (state.bids.length && state.asks.length) { | |
| state.bids[state.bids.length - 1].price += drift * 0.8; | |
| state.asks[0].price += drift * 0.8; | |
| state.spread = +(state.asks[0].price - state.bids[state.bids.length - 1].price).toFixed(2); | |
| } | |
| // Randomly change sizes | |
| for (const side of ["bids", "asks"]) { | |
| for (let i = 0; i < state[side].length; i++) { | |
| const volDelta = (Math.random() - 0.5) * 0.05; | |
| state[side][i].size = Math.max(0.01, +(state[side][i].size + volDelta).toFixed(4)); | |
| } | |
| } | |
| // Recompute day change and open | |
| state.dayChange = +((state.lastPrice - state.dayOpen) / state.dayOpen * 100).toFixed(2); | |
| } | |
| // Add a trade to the tape | |
| function pushTrade(price, size, side, exchange = state.exchange) { | |
| state.tape.unshift({ | |
| time: nowTs(), | |
| price, | |
| size, | |
| side, | |
| exchange, | |
| }); | |
| if (state.tape.length > 200) state.tape.length = 200; | |
| } | |
| // Execute against our book and return filled price and size | |
| function simulateMarketOrder(side, qty) { | |
| let remaining = qty; | |
| let avgPrice = 0; | |
| let vol = 0; | |
| const bookSide = side === "buy" ? state.asks : state.bids; // buy hits asks, sell hits bids | |
| for (let i = 0; i < bookSide.length && remaining > 0; i++) { | |
| const level = bookSide[i]; | |
| const fill = Math.min(level.size, remaining); | |
| avgPrice += level.price * fill; | |
| vol += fill; | |
| remaining -= fill; | |
| // reduce level size after fill | |
| level.size = +(level.size - fill).toFixed(4); | |
| } | |
| if (vol > 0) avgPrice /= vol; | |
| // If book exhausted, use lastPrice | |
| const fillPrice = isFinite(avgPrice) && avgPrice > 0 ? avgPrice : state.lastPrice; | |
| // Trade in tape | |
| pushTrade(+fillPrice.toFixed(2), +vol.toFixed(4), side); | |
| // Portfolio impact (simple) | |
| const sym = state.symbol; | |
| const pos = state.portfolio.positions[sym]; | |
| if (!pos) return; | |
| if (side === "buy") { | |
| // Update avg price and qty | |
| const newQty = pos.qty + vol; | |
| const cost = pos.avg * pos.qty + fillPrice * vol; | |
| pos.avg = newQty > 0 ? cost / newQty : 0; | |
| pos.qty = newQty; | |
| } else { | |
| // sell | |
| const newQty = pos.qty - vol; | |
| const proceeds = pos.avg * pos.qty - fillPrice * vol; | |
| pos.avg = newQty > 0 ? (proceeds + fillPrice * vol) / newQty : pos.avg; // approx | |
| pos.qty = newQty; | |
| } | |
| updatePortfolioUI(); | |
| return { price: fillPrice, size: vol }; | |
| } | |
| // UI: Order Book | |
| function renderOrderBook() { | |
| const bidsEl = document.getElementById("bookBids"); | |
| const asksEl = document.getElementById("bookAsks"); | |
| const spreadEl = document.getElementById("bookSpread"); | |
| const tsEl = document.getElementById("bookTs"); | |
| bidsEl.innerHTML = ""; | |
| asksEl.innerHTML = ""; | |
| // Map sizes to gradient intensity | |
| const maxBidVol = state.bids.reduce((m, l) => Math.max(m, l.size), 0.001); | |
| const maxAskVol = state.asks.reduce((m, l) => Math.max(m, l.size), 0.001); | |
| // Bids | |
| for (const level of state.bids) { | |
| const intensity = level.size / maxBidVol; | |
| const row = document.createElement("div"); | |
| row.className = "grid grid-cols-3 px-3 py-1 hover:bg-indigo-500/10 rounded transition-all duration-200"; | |
| row.innerHTML = ` | |
| <div class="text-left text-emerald-400 font-mono">${fmtSize(level.size)}</div> | |
| <div class="text-center font-semibold text-white">${fmtNum(level.price, 1)}</div> | |
| <div class="text-right text-gray-500">-</div> | |
| `; | |
| // Gradient background bar | |
| row.style.background = `linear-gradient(to right, rgba(16,185,129,${0.15 * intensity}), rgba(16,185,129,0.02) 80%)`; | |
| bidsEl.appendChild(row); | |
| } | |
| // Asks | |
| for (const level of state.asks) { | |
| const intensity = level.size / maxAskVol; | |
| const row = document.createElement("div"); | |
| row.className = "grid grid-cols-3 px-3 py-1 hover:bg-indigo-500/10 rounded transition-all duration-200"; | |
| row.innerHTML = ` | |
| <div class="text-left text-gray-500">-</div> | |
| <div class="text-center font-semibold text-white">${fmtNum(level.price, 1)}</div> | |
| <div class="text-right text-rose-400 font-mono">${fmtSize(level.size)}</div> | |
| `; | |
| row.style.background = `linear-gradient(to right, rgba(244,63,94,0.02) 20%, rgba(244,63,94,${0.15 * intensity}))`; | |
| asksEl.appendChild(row); | |
| } | |
| // Spread | |
| const bestBid = state.bids[state.bids.length - 1]?.price ?? state.lastPrice - state.spread / 2; | |
| const bestAsk = state.asks[0]?.price ?? state.lastPrice + state.spread / 2; | |
| spreadEl.innerHTML = `<span class="text-indigo-300">Spread:</span> <span class="font-mono text-accent">${fmtNum(bestAsk - bestBid, 2)}</span> | <span class="text-gray-400">Best:</span> <span class="text-emerald-400">${fmtNum(bestBid, 1)}</span> / <span class="text-rose-400">${fmtNum(bestAsk, 1)}</span>`; | |
| tsEl.textContent = nowTs(); | |
| } | |
| // UI: Trade Tape | |
| function renderTape() { | |
| const body = document.getElementById("tapeBody"); | |
| body.innerHTML = state.tape | |
| .map( | |
| (t) => { | |
| const value = t.price * t.size; | |
| return ` | |
| <div class="grid grid-cols-12 px-4 py-2 border-b border-accent/10 hover:bg-accent/5 transition-colors"> | |
| <div class="col-span-2 text-gray-400">${t.time}</div> | |
| <div class="col-span-3 text-right font-mono font-semibold text-white">${fmtNum(t.price, 2)}</div> | |
| <div class="col-span-3 text-right font-mono text-gray-300">${fmtSize(t.size)}</div> | |
| <div class="col-span-2 text-right"> | |
| <span class="px-2 py-1 rounded-full text-white text-[10px] font-semibold ${t.side === "buy" ? "bg-emerald-500/80" : "bg-rose-500/80"}"> | |
| ${t.side.toUpperCase()} | |
| </span> | |
| </div> | |
| <div class="col-span-2 text-right font-mono text-indigo-300">${fmtNum(value, 0)}</div> | |
| </div> | |
| `; | |
| } | |
| ) | |
| .join(""); | |
| } | |
| // UI: Header stats | |
| function renderHeader() { | |
| const lastEl = document.getElementById("lastPrice"); | |
| const dayEl = document.getElementById("dayChange"); | |
| const spreadEl = document.getElementById("spread"); | |
| const midEl = document.getElementById("midPrice"); | |
| const symEl = document.getElementById("symbolTitle"); | |
| const exEl = document.getElementById("exchangeTitle"); | |
| const totalEl = document.getElementById("totalValue"); | |
| lastEl.textContent = fmtNum(state.lastPrice, 2); | |
| dayEl.textContent = `${state.dayChange >= 0 ? "+" : ""}${state.dayChange.toFixed(2)}%`; | |
| dayEl.classList.toggle("text-emerald-400", state.dayChange >= 0); | |
| dayEl.classList.toggle("text-rose-400", state.dayChange < 0); | |
| const bestBid = state.bids[state.bids.length - 1]?.price ?? state.lastPrice - state.spread / 2; | |
| const bestAsk = state.asks[0]?.price ?? state.lastPrice + state.spread / 2; | |
| const mid = (bestBid + bestAsk) / 2; | |
| spreadEl.textContent = fmtNum(bestAsk - bestBid, 2); | |
| if (midEl) midEl.textContent = fmtNum(mid, 2); | |
| if (symEl) symEl.textContent = state.symbol.replace("USD", "/USD"); | |
| if (exEl) exEl.textContent = state.exchange; | |
| // Update total portfolio value | |
| if (totalEl) { | |
| let totalValue = state.portfolio.cash; | |
| for (const [sym, pos] of Object.entries(state.portfolio.positions)) { | |
| const mark = getSymbolPrice(sym); | |
| if (pos.qty !== 0) { | |
| totalValue += mark * pos.qty; | |
| } | |
| } | |
| totalEl.textContent = fmtUSD(totalValue); | |
| } | |
| } | |
| // UI: Watchlist | |
| function renderWatchlist() { | |
| const items = document.querySelectorAll("#watchlist .wl-item"); | |
| items.forEach((el) => { | |
| const sym = el.getAttribute("data-symbol"); | |
| let price = state.lastPrice; | |
| let change = state.dayChange; | |
| // Use per-symbol price if selected | |
| if (sym !== state.symbol) { | |
| price = simulateSymbolPrice(sym); | |
| change = simulateSymbolChange(sym); | |
| } | |
| const priceEl = el.querySelector('[data-field="price"]'); | |
| const changeEl = el.querySelector('[data-field="change"]'); | |
| priceEl.textContent = fmtNum(price, 2); | |
| changeEl.textContent = `${change >= 0 ? "+" : ""}${change.toFixed(2)}%`; | |
| changeEl.classList.toggle("text-emerald-400", change >= 0); | |
| changeEl.classList.toggle("text-rose-400", change < 0); | |
| // Visual selection | |
| const container = el.querySelector('.relative > div:last-child'); | |
| container.classList.toggle("border-indigo-400", sym === state.symbol); | |
| container.classList.toggle("ring-1", sym === state.symbol); | |
| container.classList.toggle("ring-indigo-300", sym === state.symbol); | |
| container.classList.toggle("bg-indigo-500/10", sym === state.symbol); | |
| }); | |
| } | |
| // UI: Portfolio | |
| function updatePortfolioUI() { | |
| const pnlEl = document.getElementById("pnl"); | |
| const cashEl = document.getElementById("cash"); | |
| const marginEl = document.getElementById("marginUsed"); | |
| const holdingsEl = document.getElementById("holdings"); | |
| let unrealized = 0; | |
| for (const [sym, pos] of Object.entries(state.portfolio.positions)) { | |
| const mark = getSymbolPrice(sym); | |
| if (pos.qty !== 0) { | |
| unrealized += (mark - pos.avg) * pos.qty; | |
| } | |
| } | |
| pnlEl.textContent = fmtUSD(unrealized); | |
| pnlEl.classList.toggle("text-emerald-400", unrealized >= 0); | |
| pnlEl.classList.toggle("text-rose-400", unrealized < 0); | |
| cashEl.textContent = fmtUSD(state.portfolio.cash); | |
| marginEl.textContent = fmtUSD(state.portfolio.marginUsed); | |
| holdingsEl.innerHTML = Object.entries(state.portfolio.positions) | |
| .filter(([_, pos]) => pos.qty !== 0) | |
| .map( | |
| ([sym, pos]) => { | |
| const mark = getSymbolPrice(sym); | |
| const value = mark * pos.qty; | |
| const pnl = (mark - pos.avg) * pos.qty; | |
| return ` | |
| <li class="p-2 rounded-lg bg-dark/50 border border-indigo-500/10"> | |
| <div class="flex items-center justify-between mb-1"> | |
| <span class="text-xs text-gray-400">${sym}</span> | |
| <span class="text-xs font-mono ${pnl >= 0 ? 'text-emerald-400' : 'text-rose-400'}">${fmtUSD(pnl)}</span> | |
| </div> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-sm font-semibold">${pos.qty.toFixed(4)}</span> | |
| <span class="text-xs text-gray-500">${fmtUSD(value)}</span> | |
| </div> | |
| </li> | |
| `; | |
| } | |
| ) | |
| .join(""); | |
| } | |
| function getSymbolPrice(sym) { | |
| if (sym === state.symbol) return state.lastPrice; | |
| // Mock other symbols around BTC with small offset | |
| const map = { ETHUSD: 3200, SOLUSD: 160 }; | |
| return map[sym] ?? 100; | |
| } | |
| function simulateSymbolPrice(sym) { | |
| return getSymbolPrice(sym) * (1 + (Math.random() - 0.5) * 0.002); | |
| } | |
| function simulateSymbolChange(sym) { | |
| const base = sym === "ETHUSD" ? 1.2 : sym === "SOLUSD" ? 2.1 : 0.8; | |
| return +((Math.random() - 0.5) * base).toFixed(2); | |
| } | |
| // Bots setup | |
| function initBots() { | |
| const container = document.getElementById("botsPanel"); | |
| container.innerHTML = ""; | |
| const bots = [ | |
| { | |
| id: "mm-btc", | |
| name: "Market Maker BTC", | |
| symbol: "BTCUSD", | |
| type: "Market Making", | |
| params: { | |
| skew: 0.05, | |
| quoteSize: 0.25, | |
| maxPositions: 2.0, | |
| a: 0.02, | |
| b: 0.01, | |
| minEdge: 0.2, | |
| }, | |
| color: "green", | |
| }, | |
| { | |
| id: "twap-eth", | |
| name: "TWAP ETH", | |
| symbol: "ETHUSD", | |
| type: "TWAP", | |
| params: { | |
| side: "buy", | |
| intervalMs: 2000, | |
| sliceQty: 0.4, | |
| remaining: 8.0, | |
| }, | |
| color: "blue", | |
| }, | |
| { | |
| id: "arb-sol", | |
| name: "Cross-venue Arb SOL", | |
| symbol: "SOLUSD", | |
| type: "Arbitrage", | |
| params: { | |
| threshold: 0.6, | |
| qty: 0.5, | |
| }, | |
| color: "purple", | |
| }, | |
| { | |
| id: "trend-btc", | |
| name: "Trend Follower BTC", | |
| symbol: "BTCUSD", | |
| type: "Momentum", | |
| params: { | |
| window: 20, | |
| threshold: 0.6, | |
| qty: 0.8, | |
| }, | |
| color: "amber", | |
| }, | |
| ]; | |
| bots.forEach((b) => { | |
| const el = document.createElement("bot-card"); | |
| el.bot = b; | |
| container.appendChild(el); | |
| }); | |
| state.bots = bots; | |
| } | |
| // Bot engine loop | |
| function botEngineLoop() { | |
| const container = document.getElementById("botsPanel"); | |
| const cards = Array.from(container.querySelectorAll("bot-card")); | |
| const getBotById = (id) => state.bots.find((b) => b.id === id); | |
| for (const card of cards) { | |
| const bot = getBotById(card.bot.id); | |
| if (!bot || bot.paused) continue; | |
| switch (bot.type) { | |
| case "Market Making": | |
| marketMaker(bot, card); | |
| break; | |
| case "TWAP": | |
| twap(bot, card); | |
| break; | |
| case "Arbitrage": | |
| arbitrage(bot, card); | |
| break; | |
| case "Momentum": | |
| momentum(bot, card); | |
| break; | |
| } | |
| card.updateUI(); | |
| } | |
| } | |
| // Bot strategies (simplified and mocked) | |
| function marketMaker(bot, card) { | |
| // MM places quotes around mid with a small skew. | |
| // Skew buy quotes slightly lower and sell quotes slightly higher based on inventory. | |
| const sym = bot.symbol; | |
| if (sym !== state.symbol) return; // only active for selected symbol | |
| const pos = state.portfolio.positions[sym] || { qty: 0 }; | |
| const inv = pos.qty; | |
| const bestBid = state.bids[state.bids.length - 1]?.price ?? state.lastPrice - state.spread / 2; | |
| const bestAsk = state.asks[0]?.price ?? state.lastPrice + state.spread / 2; | |
| const mid = (bestBid + bestAsk) / 2; | |
| const baseBid = mid - bot.params.minEdge; | |
| const baseAsk = mid + bot.params.minEdge; | |
| const skew = bot.params.skew; | |
| // Place "internal" quotes at base +/- skew relative to inventory | |
| const fakeBidPrice = baseBid - inv * skew; | |
| const fakeAskPrice = baseAsk - inv * skew; | |
| // "Hitting" logic: if our fake quotes are better than top-of-book by some edge, simulate execution | |
| const edge = Math.random() * 0.05; // 0-5 cents randomness | |
| if (Math.random() < 0.6) { | |
| // 60% chance to hit a side randomly | |
| const side = Math.random() < 0.5 ? "buy" : "sell"; | |
| const edgePrice = side === "buy" ? fakeAskPrice - edge : fakeBidPrice + edge; | |
| const top = side === "buy" ? state.asks[0]?.price : state.bids[state.bids.length - 1]?.price; | |
| if (top && Math.abs(edgePrice - top) < 0.8) { | |
| simulateMarketOrder(side, bot.params.quoteSize); | |
| card.log(`${side.toUpperCase()} filled ${bot.params.quoteSize} @ ${fmtNum(state.lastPrice, 2)}`); | |
| } | |
| } | |
| card.setQuotes({ | |
| bid: fakeBidPrice, | |
| ask: fakeAskPrice, | |
| spread: fakeAskPrice - fakeBidPrice, | |
| }); | |
| } | |
| function twap(bot, card) { | |
| if (!bot._next) bot._next = performance.now() + bot.params.intervalMs; | |
| const now = performance.now(); | |
| if (now >= bot._next) { | |
| bot._next = now + bot.params.intervalMs; | |
| const qty = Math.min(bot.params.sliceQty, bot.params.remaining); | |
| if (qty > 0) { | |
| simulateMarketOrder(bot.params.side, qty); | |
| bot.params.remaining = +(bot.params.remaining - qty).toFixed(4); | |
| card.log(`${bot.params.side.toUpperCase()} ${qty} via TWAP @ ${fmtNum(state.lastPrice, 2)}`); | |
| } else { | |
| card.log(`TWAP complete`); | |
| bot.paused = true; | |
| } | |
| } | |
| card.setProgress((1 - bot.params.remaining / 8.0) * 100); | |
| } | |
| function arbitrage(bot, card) { | |
| // Simulate cross-venue: if spread exceeds threshold, flip side | |
| const threshold = bot.params.threshold; | |
| const bestBid = state.bids[state.bids.length - 1]?.price ?? state.lastPrice - state.spread / 2; | |
| const bestAsk = state.asks[0]?.price ?? state.lastPrice + state.spread / 2; | |
| const spread = bestAsk - bestBid; | |
| if (spread > threshold && Math.random() < 0.3) { | |
| simulateMarketOrder("buy", bot.params.qty); | |
| simulateMarketOrder("sell", bot.params.qty); | |
| card.log(`ARB ${bot.params.qty} both sides @ spread ${fmtNum(spread, 2)}`); | |
| } | |
| card.setMetric("Spread", fmtNum(spread, 2)); | |
| } | |
| function momentum(bot, card) { | |
| // Very simple: compute recent fake momentum from lastPrice diffs | |
| if (!bot._window) { | |
| bot._window = []; | |
| bot._next = performance.now() + 500; | |
| } | |
| const now = performance.now(); | |
| if (now >= bot._next) { | |
| bot._next = now + 500; | |
| bot._window.push(state.lastPrice); | |
| if (bot._window.length > bot.params.window) bot._window.shift(); | |
| } | |
| const arr = bot._window; | |
| if (arr.length >= 2) { | |
| const delta = arr[arr.length - 1] - arr[0]; | |
| if (Math.abs(delta) > bot.params.threshold) { | |
| const side = delta > 0 ? "buy" : "sell"; | |
| simulateMarketOrder(side, bot.params.qty); | |
| card.log(`${side.toUpperCase()} momentum ${fmtNum(delta, 2)}`); | |
| } | |
| } | |
| card.setMetric("Window", bot.params.window); | |
| } | |
| // Event: select symbol in watchlist | |
| function wireWatchlist() { | |
| document.querySelectorAll("#watchlist .wl-item").forEach((el) => { | |
| el.addEventListener("click", () => { | |
| const sym = el.getAttribute("data-symbol"); | |
| if (sym === state.symbol) return; | |
| state.symbol = sym; | |
| state.dayOpen = state.lastPrice = getSymbolPrice(sym); | |
| state.exchange = "BINANCE"; | |
| initBook(); | |
| renderHeader(); | |
| renderWatchlist(); | |
| initBots(); // re-init bots for simplicity | |
| }); | |
| }); | |
| } | |
| // Top-level loop | |
| function tick() { | |
| if (!state.pausedAll) { | |
| tickMicrostructure(); | |
| renderHeader(); | |
| renderOrderBook(); | |
| renderWatchlist(); | |
| renderTape(); | |
| botEngineLoop(); | |
| } | |
| } | |
| // Controls | |
| function wireControls() { | |
| const pauseBtn = document.getElementById("pauseAllBtn"); | |
| pauseBtn.addEventListener("click", () => { | |
| state.pausedAll = !state.pausedAll; | |
| pauseBtn.innerHTML = state.pausedAll | |
| ? `<i data-feather="play-circle" class="w-4 h-4"></i> <span class="text-sm">Resume</span>` | |
| : `<i data-feather="pause-circle" class="w-4 h-4"></i> <span class="text-sm">Pause</span>`; | |
| feather.replace(); | |
| }); | |
| const refreshBtn = document.getElementById("refreshBtn"); | |
| if (refreshBtn) { | |
| refreshBtn.addEventListener("click", () => { | |
| state.dayOpen = state.lastPrice; | |
| renderHeader(); | |
| renderWatchlist(); | |
| }); | |
| } | |
| } | |
| // Initialize | |
| function init() { | |
| wireWatchlist(); | |
| wireControls(); | |
| initBook(); | |
| initBots(); | |
| renderHeader(); | |
| renderOrderBook(); | |
| renderTape(); | |
| renderWatchlist(); | |
| updatePortfolioUI(); | |
| // Preload tape with some trades | |
| for (let i = 0; i < 12; i++) { | |
| const side = Math.random() < 0.5 ? "buy" : "sell"; | |
| pushTrade(state.lastPrice + (Math.random() - 0.5) * 2, Math.random() * 1.5 + 0.2, side); | |
| } | |
| renderTape(); | |
| setInterval(tick, 50); // ~50ms | |
| } | |
| // Start | |
| init(); | |
| })(); |