diff --git a/web/cob_dashboard.html b/web/cob_dashboard.html index 3b390e3..af844ba 100644 --- a/web/cob_dashboard.html +++ b/web/cob_dashboard.html @@ -154,6 +154,35 @@ margin-top: 4px; } + .mini-chart-container { + margin: 8px 0; + padding: 6px; + background-color: #0a0a0a; + border-radius: 3px; + border: 1px solid #333; + } + + .mini-chart { + width: 100%; + height: 60px; + position: relative; + background-color: #111; + border-radius: 2px; + } + + .mini-chart canvas { + width: 100%; + height: 100%; + border-radius: 2px; + } + + .chart-title { + font-size: 0.7rem; + color: #888; + text-align: center; + margin-bottom: 3px; + } + .orderbook-row { display: grid; grid-template-columns: 60px 100px 1fr 80px; @@ -386,8 +415,8 @@
-
BTC/USDT
-
Resolution: $10 buckets
+
BTC/USDT - $--
+
Resolution: $10 buckets
Side
@@ -426,8 +455,8 @@
-
ETH/USDT
-
Resolution: $1 buckets
+
ETH/USDT - $--
+
Resolution: $1 buckets
Side
@@ -493,6 +522,12 @@ avg30s: 0 } }; + + // OHLCV data storage for mini charts + let ohlcvData = { + 'BTC/USDT': [], + 'ETH/USDT': [] + }; function connectWebSocket() { if (ws) { @@ -550,7 +585,10 @@ asksCount: (cobData.asks || []).length, sampleBid: (cobData.bids || [])[0], sampleAsk: (cobData.asks || [])[0], - stats: cobData.stats + stats: cobData.stats, + hasOHLCV: !!cobData.ohlcv, + ohlcvCount: cobData.ohlcv ? cobData.ohlcv.length : 0, + sampleOHLCV: cobData.ohlcv ? cobData.ohlcv[0] : null }); // Check if WebSocket data has insufficient depth, fetch REST data @@ -565,6 +603,20 @@ currentData[symbol] = cobData; + // Process OHLCV data if available + if (cobData.ohlcv && Array.isArray(cobData.ohlcv)) { + ohlcvData[symbol] = cobData.ohlcv; + console.log(`${symbol} OHLCV data received:`, cobData.ohlcv.length, 'candles'); + + // Update mini chart after order book update + setTimeout(() => { + const prefix = symbol === 'BTC/USDT' ? 'btc' : 'eth'; + drawMiniChart(prefix, cobData.ohlcv); + }, 100); + } else { + console.log(`${symbol}: No OHLCV data in update`); + } + // Track imbalance for aggregation const stats = cobData.stats || {}; if (stats.imbalance !== undefined) { @@ -645,13 +697,21 @@ return Math.round(price / bucketSize) * bucketSize; } - function updateOrderBook(prefix, cobData, resolutionFunc) { - const bids = cobData.bids || []; - const asks = cobData.asks || []; - const stats = cobData.stats || {}; - const midPrice = stats.mid_price || 0; - - if (midPrice === 0) return; + function updateOrderBook(prefix, cobData, resolutionFunc) { + const bids = cobData.bids || []; + const asks = cobData.asks || []; + const stats = cobData.stats || {}; + const midPrice = stats.mid_price || 0; + + if (midPrice === 0) return; + + // Update title with current price + const symbol = prefix === 'btc' ? 'BTC/USDT' : 'ETH/USDT'; + const priceFormatted = midPrice.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + document.getElementById(`${prefix}-title`).textContent = `${symbol} - $${priceFormatted}`; // Use wider price range for higher resolution multipliers to maintain depth const baseRange = 0.02; // 2% base range @@ -856,9 +916,13 @@ maximumFractionDigits: 2 }); + const spreadText = data.spread ? `${data.spread.toFixed(1)} bps` : '--'; + row.innerHTML = ` -
$${priceFormatted}
-
Spread: ${data.spread.toFixed(2)} bps
+
1s OHLCV (5min)
+
+ +
`; return row; @@ -945,6 +1009,10 @@ document.querySelector('.subtitle').textContent = `Real-time COB Data | BTC ($${btcBucket} buckets) | ETH ($${ethBucket} buckets)`; + // Update resolution text in panels + document.getElementById('btc-resolution').textContent = `Resolution: $${btcBucket} buckets`; + document.getElementById('eth-resolution').textContent = `Resolution: $${ethBucket} buckets`; + // Refresh current data with new resolution if (currentData['BTC/USDT']) { updateOrderBook('btc', currentData['BTC/USDT'], getBTCResolution); @@ -956,10 +1024,136 @@ console.log(`Resolution updated to ${resolutionMultiplier}x (BTC: $${btcBucket}, ETH: $${ethBucket})`); } + function drawMiniChart(prefix, ohlcvArray) { + try { + const canvas = document.getElementById(`${prefix}-mini-chart`); + if (!canvas) { + console.log(`Canvas not found for ${prefix}-mini-chart`); + return; + } + + const ctx = canvas.getContext('2d'); + const width = canvas.width; + const height = canvas.height; + + console.log(`Drawing ${prefix} chart with ${ohlcvArray ? ohlcvArray.length : 0} candles`); + + // Clear canvas + ctx.clearRect(0, 0, width, height); + + if (!ohlcvArray || ohlcvArray.length === 0) { + // Draw "No Data" message + ctx.fillStyle = '#555'; + ctx.font = '12px Courier New'; + ctx.textAlign = 'center'; + ctx.fillText('No Data', width / 2, height / 2); + console.log(`${prefix}: No OHLCV data to draw`); + return; + } + + // Get price range for scaling + const prices = []; + ohlcvArray.forEach(candle => { + prices.push(candle.high, candle.low); + }); + + const minPrice = Math.min(...prices); + const maxPrice = Math.max(...prices); + const priceRange = maxPrice - minPrice; + + if (priceRange === 0) return; + + // Calculate candle width and spacing + const candleWidth = Math.max(1, Math.floor(width / ohlcvArray.length) - 1); + const candleSpacing = width / ohlcvArray.length; + + // Draw candlesticks + ohlcvArray.forEach((candle, index) => { + const x = index * candleSpacing + candleSpacing / 2; + + // Scale prices to canvas height (inverted Y axis) + const highY = (maxPrice - candle.high) / priceRange * (height - 4) + 2; + const lowY = (maxPrice - candle.low) / priceRange * (height - 4) + 2; + const openY = (maxPrice - candle.open) / priceRange * (height - 4) + 2; + const closeY = (maxPrice - candle.close) / priceRange * (height - 4) + 2; + + // Determine candle color + const isGreen = candle.close >= candle.open; + const color = isGreen ? '#4ecdc4' : '#ff6b6b'; + + // Draw high-low line + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, highY); + ctx.lineTo(x, lowY); + ctx.stroke(); + + // Draw candle body + const bodyTop = Math.min(openY, closeY); + const bodyHeight = Math.abs(closeY - openY); + + ctx.fillStyle = color; + if (bodyHeight < 1) { + // Doji or very small body - draw as line + ctx.fillRect(x - candleWidth/2, bodyTop, candleWidth, Math.max(1, bodyHeight)); + } else { + // Normal candle body + if (isGreen) { + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.strokeRect(x - candleWidth/2, bodyTop, candleWidth, bodyHeight); + } else { + ctx.fillRect(x - candleWidth/2, bodyTop, candleWidth, bodyHeight); + } + } + }); + + // Draw current price line + if (ohlcvArray.length > 0) { + const lastCandle = ohlcvArray[ohlcvArray.length - 1]; + const currentPriceY = (maxPrice - lastCandle.close) / priceRange * (height - 4) + 2; + + ctx.strokeStyle = '#00ff88'; + ctx.lineWidth = 1; + ctx.setLineDash([2, 2]); + ctx.beginPath(); + ctx.moveTo(0, currentPriceY); + ctx.lineTo(width, currentPriceY); + ctx.stroke(); + ctx.setLineDash([]); + } + + } catch (error) { + console.error(`Error drawing mini chart for ${prefix}:`, error); + } + } + + function initializeCharts() { + // Initialize empty charts + ['btc', 'eth'].forEach(prefix => { + const canvas = document.getElementById(`${prefix}-mini-chart`); + if (canvas) { + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#555'; + ctx.font = '12px Courier New'; + ctx.textAlign = 'center'; + ctx.fillText('Waiting for data...', canvas.width / 2, canvas.height / 2); + console.log(`Initialized ${prefix} chart canvas`); + } + }); + } + // Initialize dashboard document.addEventListener('DOMContentLoaded', function() { updateStatus('Connecting...', false); + // Initialize charts with placeholder + setTimeout(() => { + initializeCharts(); + }, 100); + // Auto-connect on load setTimeout(() => { connectWebSocket(); @@ -977,6 +1171,16 @@ }); } }, 3000); // Check every 3 seconds + + // Also periodically update charts from stored data + setInterval(() => { + ['BTC/USDT', 'ETH/USDT'].forEach(symbol => { + if (ohlcvData[symbol] && ohlcvData[symbol].length > 0) { + const prefix = symbol === 'BTC/USDT' ? 'btc' : 'eth'; + drawMiniChart(prefix, ohlcvData[symbol]); + } + }); + }, 5000); // Update charts every 5 seconds }); diff --git a/web/cob_realtime_dashboard.py b/web/cob_realtime_dashboard.py index bb7c1dd..1565712 100644 --- a/web/cob_realtime_dashboard.py +++ b/web/cob_realtime_dashboard.py @@ -62,6 +62,14 @@ class COBDashboardServer: symbol: deque(maxlen=100) for symbol in self.symbols } + # OHLCV data for mini charts (5 minutes = 300 1-second candles) + self.ohlcv_data: Dict[str, deque] = { + symbol: deque(maxlen=300) for symbol in self.symbols + } + + # Current candle data (building 1-second candles) + self.current_candles: Dict[str, Dict] = {} + # Setup routes and CORS self._setup_routes() self._setup_cors() @@ -312,6 +320,9 @@ class COBDashboardServer: try: logger.debug(f"Received COB update for {symbol}") + # Process OHLCV data from mid price + await self._process_ohlcv_update(symbol, data) + # Update cache self.latest_cob_data[symbol] = data self.update_timestamps[symbol].append(datetime.now()) @@ -323,17 +334,84 @@ class COBDashboardServer: except Exception as e: logger.error(f"Error handling COB update for {symbol}: {e}") + + async def _process_ohlcv_update(self, symbol: str, data: Dict): + """Process price updates into 1-second OHLCV candles""" + try: + stats = data.get('stats', {}) + mid_price = stats.get('mid_price', 0) + + if mid_price <= 0: + return + + now = datetime.now() + current_second = now.replace(microsecond=0) + + # Get or create current candle + if symbol not in self.current_candles: + self.current_candles[symbol] = { + 'timestamp': current_second, + 'open': mid_price, + 'high': mid_price, + 'low': mid_price, + 'close': mid_price, + 'volume': 0, # We don't have volume from order book, use tick count + 'tick_count': 1 + } + else: + current_candle = self.current_candles[symbol] + + # Check if we need to close current candle and start new one + if current_second > current_candle['timestamp']: + # Close previous candle + finished_candle = { + 'timestamp': current_candle['timestamp'].isoformat(), + 'open': current_candle['open'], + 'high': current_candle['high'], + 'low': current_candle['low'], + 'close': current_candle['close'], + 'volume': current_candle['tick_count'], # Use tick count as volume + 'tick_count': current_candle['tick_count'] + } + + # Add to OHLCV history + self.ohlcv_data[symbol].append(finished_candle) + + # Start new candle + self.current_candles[symbol] = { + 'timestamp': current_second, + 'open': mid_price, + 'high': mid_price, + 'low': mid_price, + 'close': mid_price, + 'volume': 0, + 'tick_count': 1 + } + else: + # Update current candle + current_candle['high'] = max(current_candle['high'], mid_price) + current_candle['low'] = min(current_candle['low'], mid_price) + current_candle['close'] = mid_price + current_candle['tick_count'] += 1 + + except Exception as e: + logger.error(f"Error processing OHLCV update for {symbol}: {e}") async def _broadcast_cob_update(self, symbol: str, data: Dict): """Broadcast COB update to all connected WebSocket clients""" if not self.websocket_connections: return + # Add OHLCV data to the broadcast + enhanced_data = data.copy() + if symbol in self.ohlcv_data: + enhanced_data['ohlcv'] = list(self.ohlcv_data[symbol]) + message = { 'type': 'cob_update', 'symbol': symbol, 'timestamp': datetime.now().isoformat(), - 'data': data + 'data': enhanced_data } # Send to all connections @@ -382,6 +460,7 @@ class COBDashboardServer: 'bids': [], 'asks': [], 'svp': {'data': []}, + 'ohlcv': [], 'stats': { 'mid_price': 0, 'spread_bps': 0,