1253 lines
48 KiB
HTML
1253 lines
48 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>COB Order Books - BTC & ETH</title>
|
|
<style>
|
|
body {
|
|
font-family: 'Courier New', monospace;
|
|
margin: 0;
|
|
padding: 15px;
|
|
background-color: #0a0a0a;
|
|
color: #ffffff;
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.header {
|
|
text-align: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.header h1 {
|
|
color: #00ff88;
|
|
margin: 0;
|
|
font-size: 1.8rem;
|
|
}
|
|
|
|
.header .subtitle {
|
|
color: #888;
|
|
font-size: 0.9rem;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.status {
|
|
text-align: center;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
margin-bottom: 20px;
|
|
font-weight: bold;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.status.connected {
|
|
background-color: #0a2a0a;
|
|
color: #00ff88;
|
|
border: 1px solid #00ff88;
|
|
}
|
|
|
|
.status.disconnected {
|
|
background-color: #2a0a0a;
|
|
color: #ff4444;
|
|
border: 1px solid #ff4444;
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
button {
|
|
background-color: #1a1a1a;
|
|
color: white;
|
|
border: 1px solid #333;
|
|
padding: 8px 15px;
|
|
border-radius: 3px;
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
button:hover {
|
|
background-color: #2a2a2a;
|
|
}
|
|
|
|
.orderbooks-container {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 20px;
|
|
height: calc(100vh - 180px);
|
|
}
|
|
|
|
.orderbook-panel {
|
|
background-color: #1a1a1a;
|
|
border-radius: 6px;
|
|
padding: 15px;
|
|
border: 1px solid #333;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.orderbook-title {
|
|
color: #00ff88;
|
|
font-size: 1.2rem;
|
|
font-weight: bold;
|
|
margin-bottom: 10px;
|
|
text-align: center;
|
|
border-bottom: 1px solid #333;
|
|
padding-bottom: 10px;
|
|
}
|
|
|
|
.price-resolution {
|
|
color: #888;
|
|
font-size: 0.8rem;
|
|
text-align: center;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.orderbook-header {
|
|
display: grid;
|
|
grid-template-columns: 80px 1fr 1fr 1fr;
|
|
gap: 8px;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid #333;
|
|
margin-bottom: 10px;
|
|
font-size: 0.85rem;
|
|
font-weight: bold;
|
|
color: #888;
|
|
text-align: center;
|
|
}
|
|
|
|
.orderbook-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.unified-orderbook {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.mid-price-section {
|
|
padding: 10px 0;
|
|
text-align: center;
|
|
border-top: 2px solid #00ff88;
|
|
border-bottom: 2px solid #00ff88;
|
|
margin: 5px 0;
|
|
background-color: #0f0f0f;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 100;
|
|
}
|
|
|
|
.mid-price {
|
|
font-size: 1.4rem;
|
|
font-weight: bold;
|
|
color: #00ff88;
|
|
}
|
|
|
|
.spread {
|
|
font-size: 0.9rem;
|
|
color: #888;
|
|
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;
|
|
gap: 4px;
|
|
padding: 0;
|
|
margin: 0;
|
|
font-size: 0.75rem;
|
|
text-align: center;
|
|
border-bottom: 1px solid #1a1a1a;
|
|
transition: background-color 0.2s;
|
|
position: relative;
|
|
min-height: 18px;
|
|
align-items: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.orderbook-row:hover {
|
|
background-color: rgba(42, 42, 42, 0.3);
|
|
z-index: 10;
|
|
}
|
|
|
|
.ask-row {
|
|
color: #ff6b6b;
|
|
}
|
|
|
|
.bid-row {
|
|
color: #4ecdc4;
|
|
}
|
|
|
|
.volume-bar {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
height: 100%;
|
|
opacity: 1;
|
|
z-index: 1;
|
|
transition: none;
|
|
border: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.ask-bar {
|
|
background: linear-gradient(90deg, rgba(255, 107, 107, 0.6) 0%, rgba(255, 107, 107, 1.0) 100%);
|
|
border-right: 1px solid #ff6b6b;
|
|
}
|
|
|
|
.bid-bar {
|
|
background: linear-gradient(90deg, rgba(78, 205, 196, 0.6) 0%, rgba(78, 205, 196, 1.0) 100%);
|
|
border-right: 1px solid #4ecdc4;
|
|
}
|
|
|
|
.price-label {
|
|
display: none;
|
|
}
|
|
|
|
.price-label.show {
|
|
display: block;
|
|
}
|
|
|
|
.side-cell, .price-cell, .size-cell, .total-cell {
|
|
position: relative;
|
|
z-index: 2;
|
|
background: transparent;
|
|
padding: 2px 4px;
|
|
margin: 0;
|
|
}
|
|
|
|
.side-cell {
|
|
font-weight: bold;
|
|
font-size: 0.7rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.ask-side {
|
|
color: #ff6b6b;
|
|
text-shadow: 0 0 2px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.bid-side {
|
|
color: #4ecdc4;
|
|
text-shadow: 0 0 2px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.price-cell {
|
|
font-weight: bold;
|
|
text-shadow: 0 0 2px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.size-cell {
|
|
color: #ccc;
|
|
text-shadow: 0 0 2px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.total-cell {
|
|
color: #888;
|
|
font-size: 0.8rem;
|
|
text-shadow: 0 0 2px rgba(0,0,0,0.8);
|
|
}
|
|
|
|
.stats-summary {
|
|
margin-top: 15px;
|
|
padding: 10px;
|
|
background-color: #0f0f0f;
|
|
border-radius: 4px;
|
|
border: 1px solid #333;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 10px;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.stat-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.stat-label {
|
|
color: #888;
|
|
}
|
|
|
|
.stat-value {
|
|
color: #00ff88;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.loading {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100px;
|
|
color: #888;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
/* Update indicator */
|
|
.update-indicator {
|
|
position: fixed;
|
|
top: 10px;
|
|
right: 10px;
|
|
padding: 5px 10px;
|
|
background-color: #00ff88;
|
|
color: #000;
|
|
border-radius: 3px;
|
|
font-size: 0.8rem;
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
}
|
|
|
|
.update-indicator.show {
|
|
opacity: 1;
|
|
}
|
|
|
|
.controls button:hover {
|
|
background-color: #555;
|
|
}
|
|
|
|
.resolution-control {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
margin-left: 20px;
|
|
min-width: 300px;
|
|
}
|
|
|
|
.resolution-control label {
|
|
color: #ccc;
|
|
font-size: 0.9rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.resolution-control input[type="range"] {
|
|
width: 100%;
|
|
height: 6px;
|
|
background: #333;
|
|
outline: none;
|
|
border-radius: 3px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.resolution-control input[type="range"]::-webkit-slider-thumb {
|
|
appearance: none;
|
|
width: 16px;
|
|
height: 16px;
|
|
background: #00ff88;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.resolution-control input[type="range"]::-moz-range-thumb {
|
|
width: 16px;
|
|
height: 16px;
|
|
background: #00ff88;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
border: none;
|
|
}
|
|
|
|
.resolution-control small {
|
|
color: #888;
|
|
font-size: 0.75rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="update-indicator" id="updateIndicator">Updates/sec: 0</div>
|
|
|
|
<div class="header">
|
|
<h1>Consolidated Order Books</h1>
|
|
<div class="subtitle">Real-time COB Data | BTC ($10 buckets) | ETH ($1 buckets)</div>
|
|
</div>
|
|
|
|
<div class="controls">
|
|
<button onclick="toggleConnection()">Toggle Connection</button>
|
|
<button onclick="refreshData()">Refresh Data</button>
|
|
<div class="resolution-control">
|
|
<label for="resolutionSlider">Resolution Multiplier: <span id="resolutionValue">1x</span></label>
|
|
<input type="range" id="resolutionSlider" min="1" max="10" value="1" oninput="updateResolution(this.value)">
|
|
<small>1x = $10 BTC / $1 ETH | 10x = $100 BTC / $10 ETH</small>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="status" class="status disconnected">
|
|
Disconnected - Click Toggle Connection to start
|
|
</div>
|
|
|
|
<div class="orderbooks-container">
|
|
<!-- BTC Order Book -->
|
|
<div class="orderbook-panel">
|
|
<div class="orderbook-title" id="btc-title">BTC/USDT - $--</div>
|
|
<div class="price-resolution" id="btc-resolution">Resolution: $10 buckets</div>
|
|
|
|
<div class="orderbook-header">
|
|
<div>Side</div>
|
|
<div>Price</div>
|
|
<div>Size (BTC)</div>
|
|
<div>Total ($)</div>
|
|
</div>
|
|
|
|
<div class="orderbook-content">
|
|
<div class="unified-orderbook" id="btc-unified-orderbook">
|
|
<div class="loading">Loading BTC order book...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-summary">
|
|
<div class="stats-grid">
|
|
<div class="stat-item">
|
|
<span class="stat-label">Liquidity:</span>
|
|
<span class="stat-value" id="btc-liquidity">--</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Levels:</span>
|
|
<span class="stat-value" id="btc-levels">--</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Imbalance (1s/5s):</span>
|
|
<span class="stat-value" id="btc-imbalance">--</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Updates:</span>
|
|
<span class="stat-value" id="btc-updates">--</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ETH Order Book -->
|
|
<div class="orderbook-panel">
|
|
<div class="orderbook-title" id="eth-title">ETH/USDT - $--</div>
|
|
<div class="price-resolution" id="eth-resolution">Resolution: $1 buckets</div>
|
|
|
|
<div class="orderbook-header">
|
|
<div>Side</div>
|
|
<div>Price</div>
|
|
<div>Size (ETH)</div>
|
|
<div>Total ($)</div>
|
|
</div>
|
|
|
|
<div class="orderbook-content">
|
|
<div class="unified-orderbook" id="eth-unified-orderbook">
|
|
<div class="loading">Loading ETH order book...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-summary">
|
|
<div class="stats-grid">
|
|
<div class="stat-item">
|
|
<span class="stat-label">Liquidity:</span>
|
|
<span class="stat-value" id="eth-liquidity">--</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Levels:</span>
|
|
<span class="stat-value" id="eth-levels">--</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Imbalance:</span>
|
|
<span class="stat-value" id="eth-imbalance">--</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="stat-label">Updates:</span>
|
|
<span class="stat-value" id="eth-updates">--</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let ws = null;
|
|
let isConnected = false;
|
|
let updateCounts = { 'BTC/USDT': 0, 'ETH/USDT': 0 };
|
|
let lastUpdateTime = Date.now();
|
|
let totalUpdatesPerSec = 0;
|
|
let currentData = { 'BTC/USDT': null, 'ETH/USDT': null };
|
|
|
|
// Resolution multiplier for bucket size adjustment
|
|
let resolutionMultiplier = 1;
|
|
|
|
// Imbalance tracking for aggregation
|
|
let imbalanceHistory = {
|
|
'BTC/USDT': {
|
|
values: [],
|
|
avg1s: 0,
|
|
avg5s: 0,
|
|
avg15s: 0,
|
|
avg30s: 0
|
|
},
|
|
'ETH/USDT': {
|
|
values: [],
|
|
avg1s: 0,
|
|
avg5s: 0,
|
|
avg15s: 0,
|
|
avg30s: 0
|
|
}
|
|
};
|
|
|
|
// OHLCV data storage for mini charts
|
|
let ohlcvData = {
|
|
'BTC/USDT': [],
|
|
'ETH/USDT': []
|
|
};
|
|
|
|
function connectWebSocket() {
|
|
if (ws) {
|
|
ws.close();
|
|
}
|
|
|
|
ws = new WebSocket(`ws://${window.location.host}/ws`);
|
|
|
|
ws.onopen = function() {
|
|
console.log('WebSocket connected');
|
|
isConnected = true;
|
|
updateStatus('Connected - Receiving real-time COB data', true);
|
|
};
|
|
|
|
ws.onmessage = function(event) {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
console.log(`🔌 WebSocket message received:`, data.type, data.symbol || 'no symbol');
|
|
|
|
if (data.type === 'cob_update') {
|
|
handleCOBUpdate(data);
|
|
} else {
|
|
console.log(`🔌 Unhandled WebSocket message type:`, data.type);
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Error parsing WebSocket message:', error);
|
|
console.error('Raw message:', event.data);
|
|
}
|
|
};
|
|
|
|
ws.onclose = function() {
|
|
console.log('WebSocket disconnected');
|
|
isConnected = false;
|
|
updateStatus('Disconnected - Click Toggle Connection to reconnect', false);
|
|
};
|
|
|
|
ws.onerror = function(error) {
|
|
console.error('WebSocket error:', error);
|
|
updateStatus('Connection Error - Check server status', false);
|
|
};
|
|
}
|
|
|
|
function handleCOBUpdate(data) {
|
|
const symbol = data.symbol;
|
|
|
|
// Handle nested data structure from API
|
|
let cobData;
|
|
if (data.data && data.data.data) {
|
|
cobData = data.data.data;
|
|
} else if (data.data) {
|
|
cobData = data.data;
|
|
} else {
|
|
cobData = data;
|
|
}
|
|
|
|
// Debug logging to understand data structure
|
|
console.log(`🔄 ${symbol} COB Update:`, {
|
|
source: data.type || 'Unknown',
|
|
bidsCount: (cobData.bids || []).length,
|
|
asksCount: (cobData.asks || []).length,
|
|
sampleBid: (cobData.bids || [])[0],
|
|
sampleAsk: (cobData.asks || [])[0],
|
|
stats: cobData.stats,
|
|
hasOHLCV: !!cobData.ohlcv,
|
|
ohlcvCount: cobData.ohlcv ? cobData.ohlcv.length : 0,
|
|
sampleOHLCV: cobData.ohlcv ? cobData.ohlcv[0] : null,
|
|
ohlcvStructure: cobData.ohlcv ? Object.keys(cobData.ohlcv[0] || {}) : 'none'
|
|
});
|
|
|
|
// Check if WebSocket data has insufficient depth, fetch REST data
|
|
const bids = cobData.bids || [];
|
|
const asks = cobData.asks || [];
|
|
|
|
if (bids.length <= 1 && asks.length <= 1) {
|
|
console.log(`⚠️ Insufficient WS depth for ${symbol}, fetching REST data...`);
|
|
fetchRESTData(symbol);
|
|
return;
|
|
}
|
|
|
|
currentData[symbol] = cobData;
|
|
|
|
// Process OHLCV data if available
|
|
if (cobData.ohlcv && Array.isArray(cobData.ohlcv) && cobData.ohlcv.length > 0) {
|
|
ohlcvData[symbol] = cobData.ohlcv;
|
|
console.log(`📈 ${symbol} OHLCV data received:`, cobData.ohlcv.length, 'candles');
|
|
|
|
// Log first and last candle for debugging
|
|
const firstCandle = cobData.ohlcv[0];
|
|
const lastCandle = cobData.ohlcv[cobData.ohlcv.length - 1];
|
|
console.log(`📊 ${symbol} OHLCV range:`, {
|
|
first: firstCandle,
|
|
last: lastCandle,
|
|
priceRange: `${Math.min(...cobData.ohlcv.map(c => c.low))} - ${Math.max(...cobData.ohlcv.map(c => c.high))}`
|
|
});
|
|
|
|
// Update mini chart after order book update
|
|
setTimeout(() => {
|
|
const prefix = symbol === 'BTC/USDT' ? 'btc' : 'eth';
|
|
console.log(`🎨 Drawing chart for ${prefix} with ${cobData.ohlcv.length} candles`);
|
|
drawMiniChart(prefix, cobData.ohlcv);
|
|
}, 100);
|
|
} else {
|
|
console.log(`❌ ${symbol}: No valid OHLCV data in update (${cobData.ohlcv ? cobData.ohlcv.length : 'null'} items)`);
|
|
|
|
// Try to get OHLCV from REST endpoint
|
|
console.log(`🔍 Trying to fetch OHLCV from REST for ${symbol}...`);
|
|
fetchRESTData(symbol);
|
|
}
|
|
|
|
// Track imbalance for aggregation
|
|
const stats = cobData.stats || {};
|
|
if (stats.imbalance !== undefined) {
|
|
trackImbalance(symbol, stats.imbalance);
|
|
}
|
|
|
|
// Update the appropriate order book
|
|
if (symbol === 'BTC/USDT') {
|
|
updateOrderBook('btc', cobData, getBTCResolution);
|
|
} else if (symbol === 'ETH/USDT') {
|
|
updateOrderBook('eth', cobData, getETHResolution);
|
|
}
|
|
|
|
// Track update rate
|
|
updateCounts[symbol]++;
|
|
updateUpdateRate();
|
|
}
|
|
|
|
function fetchRESTData(symbol) {
|
|
console.log(`🔍 Fetching REST data for ${symbol}...`);
|
|
fetch(`/api/cob/${encodeURIComponent(symbol)}`)
|
|
.then(response => {
|
|
console.log(`📡 REST response for ${symbol}:`, response.status, response.statusText);
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
console.log(`📦 REST data received for ${symbol}:`, {
|
|
hasData: !!data.data,
|
|
dataKeys: data.data ? Object.keys(data.data) : [],
|
|
hasOHLCV: !!(data.data && data.data.ohlcv),
|
|
ohlcvCount: data.data && data.data.ohlcv ? data.data.ohlcv.length : 0
|
|
});
|
|
|
|
if (data.data) {
|
|
console.log(`✅ Processing REST fallback data for ${symbol}`);
|
|
handleCOBUpdate({symbol: symbol, data: data.data, type: 'rest_api'});
|
|
} else {
|
|
console.error(`❌ No data in REST response for ${symbol}`);
|
|
}
|
|
})
|
|
.catch(error => console.error(`❌ Error fetching REST data for ${symbol}:`, error));
|
|
}
|
|
|
|
function trackImbalance(symbol, imbalance) {
|
|
const now = Date.now();
|
|
const history = imbalanceHistory[symbol];
|
|
|
|
// Add current imbalance with timestamp
|
|
history.values.push({ value: imbalance, timestamp: now });
|
|
|
|
// Remove old values (older than 30 seconds)
|
|
const cutoff30s = now - 30000;
|
|
const cutoff15s = now - 15000;
|
|
const cutoff5s = now - 5000;
|
|
const cutoff1s = now - 1000;
|
|
|
|
history.values = history.values.filter(item => item.timestamp > cutoff30s);
|
|
|
|
// Calculate averages for different time windows
|
|
const values1s = history.values.filter(item => item.timestamp > cutoff1s);
|
|
const values5s = history.values.filter(item => item.timestamp > cutoff5s);
|
|
const values15s = history.values.filter(item => item.timestamp > cutoff15s);
|
|
const values30s = history.values;
|
|
|
|
if (values1s.length > 0) {
|
|
history.avg1s = values1s.reduce((sum, item) => sum + item.value, 0) / values1s.length;
|
|
}
|
|
|
|
if (values5s.length > 0) {
|
|
history.avg5s = values5s.reduce((sum, item) => sum + item.value, 0) / values5s.length;
|
|
}
|
|
|
|
if (values15s.length > 0) {
|
|
history.avg15s = values15s.reduce((sum, item) => sum + item.value, 0) / values15s.length;
|
|
}
|
|
|
|
if (values30s.length > 0) {
|
|
history.avg30s = values30s.reduce((sum, item) => sum + item.value, 0) / values30s.length;
|
|
}
|
|
}
|
|
|
|
function getBTCResolution(price) {
|
|
// BTC buckets: $10 base * multiplier (1x-10x = $10-$100)
|
|
const bucketSize = 10 * resolutionMultiplier;
|
|
return Math.round(price / bucketSize) * bucketSize;
|
|
}
|
|
|
|
function getETHResolution(price) {
|
|
// ETH buckets: $1 base * multiplier (1x-10x = $1-$10)
|
|
const bucketSize = 1 * resolutionMultiplier;
|
|
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;
|
|
|
|
// 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
|
|
const expandedRange = baseRange * Math.max(1, resolutionMultiplier * 2.5); // Very aggressive expansion
|
|
const priceRange = midPrice * expandedRange;
|
|
const minPrice = midPrice - priceRange;
|
|
const maxPrice = midPrice + priceRange;
|
|
|
|
// Helper function to aggregate orders by resolution buckets
|
|
function aggregateOrders(orders, isAsk = false) {
|
|
const buckets = new Map();
|
|
|
|
// First, filter orders within the expanded price range
|
|
const filteredOrders = orders.filter(order => {
|
|
return order.price >= minPrice && order.price <= maxPrice &&
|
|
(isAsk ? order.price >= midPrice : order.price <= midPrice);
|
|
});
|
|
|
|
// Aggregate into buckets
|
|
filteredOrders.forEach(order => {
|
|
const bucketPrice = resolutionFunc(order.price);
|
|
if (!buckets.has(bucketPrice)) {
|
|
buckets.set(bucketPrice, {
|
|
price: bucketPrice,
|
|
volume: 0,
|
|
value: 0,
|
|
orderCount: 0
|
|
});
|
|
}
|
|
const bucket = buckets.get(bucketPrice);
|
|
bucket.volume += order.volume || 0;
|
|
bucket.value += (order.volume || 0) * order.price;
|
|
bucket.orderCount += 1;
|
|
});
|
|
|
|
// Convert to array and ensure minimum buckets
|
|
let result = Array.from(buckets.values());
|
|
|
|
// If we have very few buckets, create additional empty ones
|
|
if (result.length > 0 && result.length < 5) {
|
|
const bucketSize = resolutionMultiplier * (prefix === 'btc' ? 10 : 1);
|
|
const baseBucket = result[isAsk ? 0 : result.length - 1];
|
|
|
|
for (let i = 0; i < 8; i++) { // Create up to 8 additional buckets
|
|
const newPrice = isAsk
|
|
? baseBucket.price + (bucketSize * (i + 1))
|
|
: baseBucket.price - (bucketSize * (i + 1));
|
|
|
|
if (newPrice > 0 &&
|
|
newPrice >= minPrice && newPrice <= maxPrice &&
|
|
(isAsk ? newPrice >= midPrice : newPrice <= midPrice)) {
|
|
result.push({
|
|
price: newPrice,
|
|
volume: 0,
|
|
value: 0,
|
|
orderCount: 0
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Aggregate or use raw data based on resolution multiplier
|
|
let processedBids, processedAsks;
|
|
|
|
if (resolutionMultiplier > 1) {
|
|
// Aggregate by resolution buckets with expanded range
|
|
processedBids = aggregateOrders(bids, false)
|
|
.sort((a, b) => b.price - a.price); // Highest price first
|
|
processedAsks = aggregateOrders(asks, true)
|
|
.sort((a, b) => a.price - b.price); // Lowest price first
|
|
|
|
console.log(`${prefix.toUpperCase()} aggregated: ${processedBids.length} bid buckets, ${processedAsks.length} ask buckets (${resolutionMultiplier}x, ±${(expandedRange*100).toFixed(1)}%)`);
|
|
} else {
|
|
// Use raw data without aggregation
|
|
processedBids = bids
|
|
.map(bid => ({
|
|
price: bid.price,
|
|
volume: bid.volume || 0,
|
|
value: (bid.volume || 0) * bid.price
|
|
}))
|
|
.filter(bid => bid.price >= minPrice && bid.price <= midPrice)
|
|
.sort((a, b) => b.price - a.price);
|
|
|
|
processedAsks = asks
|
|
.map(ask => ({
|
|
price: ask.price,
|
|
volume: ask.volume || 0,
|
|
value: (ask.volume || 0) * ask.price
|
|
}))
|
|
.filter(ask => ask.price <= maxPrice && ask.price >= midPrice)
|
|
.sort((a, b) => a.price - b.price);
|
|
}
|
|
|
|
// Increase display levels for aggregated data to show more depth
|
|
const maxDisplayLevels = resolutionMultiplier > 1 ? 75 : 50; // More levels for aggregated data
|
|
const displayBids = processedBids.slice(0, maxDisplayLevels);
|
|
const displayAsks = processedAsks.slice(0, maxDisplayLevels);
|
|
|
|
// Calculate maximum volume for bar scaling
|
|
const allVolumes = [...displayBids, ...displayAsks].map(order => order.volume);
|
|
const maxVolume = Math.max(...allVolumes, 1);
|
|
|
|
// Combine all orders and sort by price (highest to lowest)
|
|
const allOrders = [];
|
|
|
|
// Add asks first (highest ask prices at top)
|
|
displayAsks.reverse().forEach((ask, index) => {
|
|
allOrders.push({
|
|
...ask,
|
|
side: 'ASK',
|
|
showPrice: resolutionMultiplier > 1 ? true : (index % 10 === 0), // Show every price when using multiplier
|
|
volumePercent: (ask.volume / maxVolume) * 100
|
|
});
|
|
});
|
|
|
|
// Add mid-price marker
|
|
allOrders.push({
|
|
price: midPrice,
|
|
volume: 0,
|
|
value: 0,
|
|
side: 'MID',
|
|
spread: stats.spread_bps || 0,
|
|
showPrice: true,
|
|
volumePercent: 0
|
|
});
|
|
|
|
// Add bids (highest bid prices below mid)
|
|
displayBids.forEach((bid, index) => {
|
|
allOrders.push({
|
|
...bid,
|
|
side: 'BID',
|
|
showPrice: resolutionMultiplier > 1 ? true : (index % 10 === 0), // Show every price when using multiplier
|
|
volumePercent: (bid.volume / maxVolume) * 100
|
|
});
|
|
});
|
|
|
|
// Update unified order book
|
|
const unifiedOrderbook = document.getElementById(`${prefix}-unified-orderbook`);
|
|
unifiedOrderbook.innerHTML = '';
|
|
|
|
allOrders.forEach(order => {
|
|
if (order.side === 'MID') {
|
|
const midRow = createMidPriceRow(order, prefix);
|
|
unifiedOrderbook.appendChild(midRow);
|
|
} else {
|
|
const row = createOrderBookRow(order, order.side.toLowerCase(), prefix);
|
|
unifiedOrderbook.appendChild(row);
|
|
}
|
|
});
|
|
|
|
// Update statistics with actual counts
|
|
const updatedStats = {
|
|
...stats,
|
|
bid_levels: displayBids.length,
|
|
ask_levels: displayAsks.length
|
|
};
|
|
updateStatistics(prefix, updatedStats);
|
|
|
|
console.log(`${prefix.toUpperCase()}: Displayed ${displayBids.length} bids, ${displayAsks.length} asks from ${bids.length}/${asks.length} total (±${(expandedRange*100).toFixed(1)}%)`);
|
|
}
|
|
|
|
function createOrderBookRow(data, type, prefix) {
|
|
const row = document.createElement('div');
|
|
row.className = `orderbook-row ${type}-row`;
|
|
|
|
const isETH = prefix === 'eth';
|
|
const volumeDecimals = isETH ? 3 : 6;
|
|
|
|
// Only show price label every 5th row or if explicitly flagged
|
|
const showPrice = data.showPrice !== false;
|
|
const priceDisplay = showPrice ?
|
|
`$${data.price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}` :
|
|
'';
|
|
|
|
const sideText = data.side || (type === 'ask' ? 'ASK' : 'BID');
|
|
const sideClass = type === 'ask' ? 'ask-side' : 'bid-side';
|
|
|
|
// Create volume bar
|
|
const volumePercent = data.volumePercent || 0;
|
|
const barClass = type === 'ask' ? 'ask-bar' : 'bid-bar';
|
|
|
|
row.innerHTML = `
|
|
<div class="volume-bar ${barClass}" style="width: ${volumePercent}%"></div>
|
|
<div class="side-cell ${sideClass}">${sideText}</div>
|
|
<div class="price-cell">${priceDisplay}</div>
|
|
<div class="size-cell">${data.volume.toFixed(volumeDecimals)}</div>
|
|
<div class="total-cell">$${(data.value / 1000).toFixed(0)}K</div>
|
|
`;
|
|
|
|
return row;
|
|
}
|
|
|
|
function createMidPriceRow(data, prefix) {
|
|
const row = document.createElement('div');
|
|
row.className = 'mid-price-section';
|
|
|
|
const priceFormatted = data.price.toLocaleString(undefined, {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2
|
|
});
|
|
|
|
const spreadText = data.spread ? `${data.spread.toFixed(1)} bps` : '--';
|
|
|
|
row.innerHTML = `
|
|
<div class="chart-title">1s OHLCV (5min)</div>
|
|
<div class="mini-chart">
|
|
<canvas id="${prefix}-mini-chart" width="200" height="60"></canvas>
|
|
</div>
|
|
`;
|
|
|
|
return row;
|
|
}
|
|
|
|
function updateStatistics(prefix, stats) {
|
|
const totalLiq = (stats.bid_liquidity + stats.ask_liquidity) || 0;
|
|
document.getElementById(`${prefix}-liquidity`).textContent =
|
|
`$${(totalLiq / 1000).toFixed(0)}K`;
|
|
|
|
const bidCount = stats.bid_levels || 0;
|
|
const askCount = stats.ask_levels || 0;
|
|
document.getElementById(`${prefix}-levels`).textContent = `${bidCount + askCount}`;
|
|
|
|
// Show aggregated imbalance (all time windows) with color coding
|
|
const symbol = prefix === 'btc' ? 'BTC/USDT' : 'ETH/USDT';
|
|
const history = imbalanceHistory[symbol];
|
|
const imbalance1s = (history.avg1s * 100).toFixed(1);
|
|
const imbalance5s = (history.avg5s * 100).toFixed(1);
|
|
const imbalance15s = (history.avg15s * 100).toFixed(1);
|
|
const imbalance30s = (history.avg30s * 100).toFixed(1);
|
|
|
|
// Helper function to get color based on imbalance value
|
|
function getImbalanceColor(value) {
|
|
return parseFloat(value) < 0 ? '#ff6b6b' : '#00ff88';
|
|
}
|
|
|
|
// Create colored HTML for each imbalance
|
|
document.getElementById(`${prefix}-imbalance`).innerHTML =
|
|
`<span style="color: ${getImbalanceColor(imbalance1s)}">${imbalance1s}% (1s)</span> | ` +
|
|
`<span style="color: ${getImbalanceColor(imbalance5s)}">${imbalance5s}% (5s)</span> | ` +
|
|
`<span style="color: ${getImbalanceColor(imbalance15s)}">${imbalance15s}% (15s)</span> | ` +
|
|
`<span style="color: ${getImbalanceColor(imbalance30s)}">${imbalance30s}% (30s)</span>`;
|
|
|
|
document.getElementById(`${prefix}-updates`).textContent = updateCounts[symbol];
|
|
}
|
|
|
|
function updateUpdateRate() {
|
|
const now = Date.now();
|
|
if (now - lastUpdateTime >= 1000) {
|
|
totalUpdatesPerSec = updateCounts['BTC/USDT'] + updateCounts['ETH/USDT'];
|
|
|
|
const indicator = document.getElementById('updateIndicator');
|
|
indicator.textContent = `Updates/sec: ${totalUpdatesPerSec}`;
|
|
indicator.classList.add('show');
|
|
|
|
setTimeout(() => {
|
|
indicator.classList.remove('show');
|
|
}, 1000);
|
|
|
|
updateCounts['BTC/USDT'] = 0;
|
|
updateCounts['ETH/USDT'] = 0;
|
|
lastUpdateTime = now;
|
|
}
|
|
}
|
|
|
|
function updateStatus(message, connected) {
|
|
const statusEl = document.getElementById('status');
|
|
statusEl.textContent = message;
|
|
statusEl.className = `status ${connected ? 'connected' : 'disconnected'}`;
|
|
}
|
|
|
|
function toggleConnection() {
|
|
if (isConnected) {
|
|
if (ws) ws.close();
|
|
} else {
|
|
connectWebSocket();
|
|
}
|
|
}
|
|
|
|
function refreshData() {
|
|
if (isConnected) {
|
|
['BTC/USDT', 'ETH/USDT'].forEach(symbol => {
|
|
fetch(`/api/cob/${encodeURIComponent(symbol)}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.data) {
|
|
handleCOBUpdate({symbol: symbol, data: data.data});
|
|
}
|
|
})
|
|
.catch(error => console.error(`Error refreshing ${symbol} data:`, error));
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateResolution(value) {
|
|
resolutionMultiplier = parseInt(value);
|
|
document.getElementById('resolutionValue').textContent = `${resolutionMultiplier}x`;
|
|
|
|
// Update subtitle to show current bucket sizes
|
|
const btcBucket = 10 * resolutionMultiplier;
|
|
const ethBucket = 1 * resolutionMultiplier;
|
|
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);
|
|
}
|
|
if (currentData['ETH/USDT']) {
|
|
updateOrderBook('eth', currentData['ETH/USDT'], getETHResolution);
|
|
}
|
|
|
|
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.error(`❌ 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 (${width}x${height})`);
|
|
|
|
// Clear canvas with background
|
|
ctx.fillStyle = '#111';
|
|
ctx.fillRect(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;
|
|
}
|
|
|
|
// Validate OHLCV data structure
|
|
const firstCandle = ohlcvArray[0];
|
|
if (!firstCandle || typeof firstCandle.open === 'undefined' || typeof firstCandle.close === 'undefined') {
|
|
console.error(`❌ ${prefix}: Invalid OHLCV data structure:`, firstCandle);
|
|
ctx.fillStyle = '#ff6b6b';
|
|
ctx.font = '10px Courier New';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Invalid Data', width / 2, height / 2);
|
|
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;
|
|
|
|
console.log(`📊 ${prefix} price range: $${minPrice.toFixed(2)} - $${maxPrice.toFixed(2)} (range: $${priceRange.toFixed(2)})`);
|
|
|
|
if (priceRange === 0) {
|
|
console.warn(`⚠️ ${prefix}: Zero price range, cannot draw chart`);
|
|
ctx.fillStyle = '#ff6b6b';
|
|
ctx.font = '10px Courier New';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Zero Range', width / 2, height / 2);
|
|
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([]);
|
|
}
|
|
|
|
console.log(`✅ Successfully drew ${prefix} chart with ${ohlcvArray.length} candles`);
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Error drawing mini chart for ${prefix}:`, error);
|
|
console.error(error.stack);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}, 500);
|
|
|
|
// Periodically fetch REST data to ensure depth
|
|
setInterval(() => {
|
|
if (isConnected) {
|
|
['BTC/USDT', 'ETH/USDT'].forEach(symbol => {
|
|
const data = currentData[symbol];
|
|
if (!data || (data.bids && data.asks && data.bids.length <= 2 && data.asks.length <= 2)) {
|
|
console.log(`Fetching REST data for ${symbol} - insufficient depth`);
|
|
fetchRESTData(symbol);
|
|
}
|
|
});
|
|
}
|
|
}, 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
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |