Files
gogo2/web/cob_dashboard.html
Dobromir Popov 72b010631a small ui fixes
2025-06-18 20:17:11 +03:00

1187 lines
44 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);
if (data.type === 'cob_update') {
handleCOBUpdate(data);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
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:`, {
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
});
// 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)) {
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) {
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) {
fetch(`/api/cob/${encodeURIComponent(symbol)}`)
.then(response => response.json())
.then(data => {
if (data.data) {
console.log(`REST fallback data for ${symbol}:`, data.data);
handleCOBUpdate({symbol: symbol, data: data.data});
}
})
.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)
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);
document.getElementById(`${prefix}-imbalance`).textContent =
`${imbalance1s}% (1s) | ${imbalance5s}% (5s) | ${imbalance15s}% (15s) | ${imbalance30s}% (30s)`;
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.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();
}, 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>