COB data and dash
This commit is contained in:
689
web/cob_dashboard.html
Normal file
689
web/cob_dashboard.html
Normal file
@ -0,0 +1,689 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Consolidated Order Book Dashboard</title>
|
||||
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Arial', sans-serif;
|
||||
margin: 0;
|
||||
padding: 15px;
|
||||
background-color: #0a0a0a;
|
||||
color: #ffffff;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #00ff88;
|
||||
margin: 0;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.header .subtitle {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
select, button {
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
border: 1px solid #333;
|
||||
padding: 6px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: #2a2a2a;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
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;
|
||||
}
|
||||
|
||||
.dashboard-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-rows: 1fr auto;
|
||||
gap: 15px;
|
||||
height: calc(100vh - 150px);
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.price-chart-container {
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.svp-chart-container {
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid #333;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.orderbook-section {
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
color: #00ff88;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.orderbook-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.orderbook-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.asks-section, .bids-section {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mid-price-section {
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
border-top: 1px solid #333;
|
||||
border-bottom: 1px solid #333;
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.mid-price {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.spread {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.orderbook-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 5px;
|
||||
padding: 2px 0;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.orderbook-row:hover {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.ask-row {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.bid-row {
|
||||
color: #4ecdc4;
|
||||
}
|
||||
|
||||
.price-cell {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.size-cell {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.total-cell {
|
||||
text-align: right;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.volume-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
opacity: 0.1;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.ask-bar {
|
||||
background-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.bid-bar {
|
||||
background-color: #4ecdc4;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
grid-column: span 2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
border: 1px solid #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #888;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #00ff88;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
color: #ccc;
|
||||
font-size: 0.7rem;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
#price-chart, #svp-chart {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Consolidated Order Book Dashboard</h1>
|
||||
<div class="subtitle">Hybrid WebSocket + REST API | Real-time + Deep Market Data</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<label for="symbolSelect">Symbol:</label>
|
||||
<select id="symbolSelect">
|
||||
<option value="BTC/USDT">BTC/USDT</option>
|
||||
<option value="ETH/USDT">ETH/USDT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<button onclick="toggleConnection()">Toggle Connection</button>
|
||||
<button onclick="refreshData()">Refresh Data</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status disconnected">
|
||||
Disconnected - Click Toggle Connection to start
|
||||
</div>
|
||||
|
||||
<div class="dashboard-container">
|
||||
<!-- Charts Section -->
|
||||
<div class="chart-section">
|
||||
<!-- Price Chart -->
|
||||
<div class="price-chart-container">
|
||||
<div class="chart-title">Price & Volume Analysis</div>
|
||||
<div id="price-chart"></div>
|
||||
</div>
|
||||
|
||||
<!-- Session Volume Profile -->
|
||||
<div class="svp-chart-container">
|
||||
<div class="chart-title">Session Volume Profile (SVP)</div>
|
||||
<div id="svp-chart"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order Book Ladder -->
|
||||
<div class="orderbook-section">
|
||||
<div class="chart-title">Order Book Ladder</div>
|
||||
|
||||
<div class="orderbook-header">
|
||||
<div>Price</div>
|
||||
<div>Size</div>
|
||||
<div>Total</div>
|
||||
</div>
|
||||
|
||||
<div class="orderbook-content">
|
||||
<!-- Asks (Sells) - Top half -->
|
||||
<div class="asks-section" id="asks-section">
|
||||
<div class="loading">Loading asks...</div>
|
||||
</div>
|
||||
|
||||
<!-- Mid Price -->
|
||||
<div class="mid-price-section">
|
||||
<div class="mid-price" id="mid-price-display">$--</div>
|
||||
<div class="spread" id="spread-display">Spread: -- bps</div>
|
||||
</div>
|
||||
|
||||
<!-- Bids (Buys) - Bottom half -->
|
||||
<div class="bids-section" id="bids-section">
|
||||
<div class="loading">Loading bids...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="stats-container">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Total Liquidity</div>
|
||||
<div class="stat-value" id="total-liquidity">--</div>
|
||||
<div class="stat-sub" id="liquidity-breakdown">--</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Book Depth</div>
|
||||
<div class="stat-value" id="book-depth">--</div>
|
||||
<div class="stat-sub" id="depth-breakdown">--</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Imbalance</div>
|
||||
<div class="stat-value" id="imbalance">--</div>
|
||||
<div class="stat-sub">bid/ask ratio</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Update Rate</div>
|
||||
<div class="stat-value" id="update-rate">--</div>
|
||||
<div class="stat-sub">updates/sec</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Best Bid</div>
|
||||
<div class="stat-value" id="best-bid">--</div>
|
||||
<div class="stat-sub" id="bid-size">--</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Best Ask</div>
|
||||
<div class="stat-value" id="best-ask">--</div>
|
||||
<div class="stat-sub" id="ask-size">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws = null;
|
||||
let currentSymbol = 'BTC/USDT';
|
||||
let isConnected = false;
|
||||
let lastUpdateTime = 0;
|
||||
let updateCount = 0;
|
||||
let currentData = null;
|
||||
|
||||
// Initialize charts
|
||||
function initializeCharts() {
|
||||
// Price Chart Layout
|
||||
const priceLayout = {
|
||||
title: '',
|
||||
xaxis: {
|
||||
title: 'Time',
|
||||
color: '#ffffff',
|
||||
gridcolor: '#333',
|
||||
showgrid: false
|
||||
},
|
||||
yaxis: {
|
||||
title: 'Price',
|
||||
color: '#ffffff',
|
||||
gridcolor: '#333'
|
||||
},
|
||||
plot_bgcolor: '#0a0a0a',
|
||||
paper_bgcolor: '#1a1a1a',
|
||||
font: { color: '#ffffff' },
|
||||
margin: { l: 60, r: 20, t: 20, b: 40 },
|
||||
showlegend: false
|
||||
};
|
||||
|
||||
// SVP Chart Layout
|
||||
const svpLayout = {
|
||||
title: '',
|
||||
xaxis: {
|
||||
title: 'Volume',
|
||||
color: '#ffffff',
|
||||
gridcolor: '#333'
|
||||
},
|
||||
yaxis: {
|
||||
title: 'Price',
|
||||
color: '#ffffff',
|
||||
gridcolor: '#333'
|
||||
},
|
||||
plot_bgcolor: '#0a0a0a',
|
||||
paper_bgcolor: '#1a1a1a',
|
||||
font: { color: '#ffffff' },
|
||||
margin: { l: 60, r: 20, t: 20, b: 40 }
|
||||
};
|
||||
|
||||
// Initialize empty charts
|
||||
Plotly.newPlot('price-chart', [], priceLayout, {responsive: true});
|
||||
Plotly.newPlot('svp-chart', [], svpLayout, {responsive: true});
|
||||
}
|
||||
|
||||
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 data', true);
|
||||
};
|
||||
|
||||
ws.onmessage = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Received data:', 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) {
|
||||
// Handle nested data structure from API
|
||||
if (data.data && data.data.data) {
|
||||
currentData = data.data.data;
|
||||
} else if (data.data) {
|
||||
currentData = data.data;
|
||||
} else {
|
||||
currentData = data;
|
||||
}
|
||||
|
||||
console.log('Processing COB data:', currentData);
|
||||
const stats = currentData.stats || {};
|
||||
|
||||
// Update statistics
|
||||
updateStatistics(stats);
|
||||
|
||||
// Update order book ladder
|
||||
updateOrderBookLadder(currentData);
|
||||
|
||||
// Update SVP chart
|
||||
updateSVPChart(currentData);
|
||||
|
||||
// Track update rate
|
||||
updateCount++;
|
||||
const now = Date.now();
|
||||
if (now - lastUpdateTime >= 1000) {
|
||||
document.getElementById('update-rate').textContent = updateCount;
|
||||
updateCount = 0;
|
||||
lastUpdateTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
function updateOrderBookLadder(cobData) {
|
||||
const bids = cobData.bids || [];
|
||||
const asks = cobData.asks || [];
|
||||
|
||||
// Sort asks (lowest price first, closest to mid)
|
||||
const sortedAsks = [...asks].sort((a, b) => a.price - b.price);
|
||||
|
||||
// Sort bids (highest price first, closest to mid)
|
||||
const sortedBids = [...bids].sort((a, b) => b.price - a.price);
|
||||
|
||||
// Update asks section (top half)
|
||||
const asksSection = document.getElementById('asks-section');
|
||||
asksSection.innerHTML = '';
|
||||
|
||||
// Show asks in reverse order (highest first, then down to mid)
|
||||
sortedAsks.reverse().forEach((ask, index) => {
|
||||
const row = createOrderBookRow(ask, 'ask');
|
||||
asksSection.appendChild(row);
|
||||
});
|
||||
|
||||
// Update bids section (bottom half)
|
||||
const bidsSection = document.getElementById('bids-section');
|
||||
bidsSection.innerHTML = '';
|
||||
|
||||
// Show bids in normal order (highest first, then down)
|
||||
sortedBids.forEach((bid, index) => {
|
||||
const row = createOrderBookRow(bid, 'bid');
|
||||
bidsSection.appendChild(row);
|
||||
});
|
||||
|
||||
// Update mid price
|
||||
const stats = cobData.stats || {};
|
||||
document.getElementById('mid-price-display').textContent = `$${(stats.mid_price || 0).toFixed(2)}`;
|
||||
document.getElementById('spread-display').textContent = `Spread: ${(stats.spread_bps || 0).toFixed(2)} bps`;
|
||||
}
|
||||
|
||||
function createOrderBookRow(data, type) {
|
||||
const row = document.createElement('div');
|
||||
row.className = `orderbook-row ${type}-row`;
|
||||
row.style.position = 'relative';
|
||||
|
||||
// Calculate total volume for bar width
|
||||
const maxVolume = currentData ? Math.max(
|
||||
...currentData.bids.map(b => b.volume),
|
||||
...currentData.asks.map(a => a.volume)
|
||||
) : 1;
|
||||
|
||||
const barWidth = (data.volume / maxVolume) * 100;
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="volume-bar ${type}-bar" style="width: ${barWidth}%"></div>
|
||||
<div class="price-cell">$${data.price.toFixed(2)}</div>
|
||||
<div class="size-cell">${(data.volume || 0).toFixed(4)}</div>
|
||||
<div class="total-cell">$${((data.volume || 0) / 1000).toFixed(0)}K</div>
|
||||
`;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function updateStatistics(stats) {
|
||||
// Total Liquidity
|
||||
const totalLiq = (stats.bid_liquidity + stats.ask_liquidity) || 0;
|
||||
document.getElementById('total-liquidity').textContent = `$${(totalLiq / 1000).toFixed(0)}K`;
|
||||
document.getElementById('liquidity-breakdown').textContent =
|
||||
`Bid: $${(stats.bid_liquidity / 1000).toFixed(0)}K | Ask: $${(stats.ask_liquidity / 1000).toFixed(0)}K`;
|
||||
|
||||
// Order Book Depth
|
||||
const bidCount = stats.bid_levels || 0;
|
||||
const askCount = stats.ask_levels || 0;
|
||||
document.getElementById('book-depth').textContent = `${bidCount + askCount}`;
|
||||
document.getElementById('depth-breakdown').textContent = `${bidCount} bids | ${askCount} asks`;
|
||||
|
||||
// Imbalance
|
||||
const imbalance = stats.imbalance || 0;
|
||||
document.getElementById('imbalance').textContent = `${(imbalance * 100).toFixed(1)}%`;
|
||||
|
||||
// Best Bid/Ask
|
||||
if (currentData && currentData.bids && currentData.bids.length > 0) {
|
||||
const bestBid = Math.max(...currentData.bids.map(b => b.price));
|
||||
const bestBidData = currentData.bids.find(b => b.price === bestBid);
|
||||
document.getElementById('best-bid').textContent = `$${bestBid.toFixed(2)}`;
|
||||
document.getElementById('bid-size').textContent = `${(bestBidData.volume || 0).toFixed(4)}`;
|
||||
}
|
||||
|
||||
if (currentData && currentData.asks && currentData.asks.length > 0) {
|
||||
const bestAsk = Math.min(...currentData.asks.map(a => a.price));
|
||||
const bestAskData = currentData.asks.find(a => a.price === bestAsk);
|
||||
document.getElementById('best-ask').textContent = `$${bestAsk.toFixed(2)}`;
|
||||
document.getElementById('ask-size').textContent = `${(bestAskData.volume || 0).toFixed(4)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSVPChart(cobData) {
|
||||
const svp = cobData.svp || [];
|
||||
|
||||
// Handle both array format and object format
|
||||
let svpData = [];
|
||||
if (Array.isArray(svp)) {
|
||||
svpData = svp;
|
||||
} else if (svp.data && Array.isArray(svp.data)) {
|
||||
svpData = svp.data;
|
||||
}
|
||||
|
||||
if (svpData.length === 0) {
|
||||
console.log('No SVP data available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare SVP data
|
||||
const buyTrace = {
|
||||
x: svpData.map(d => -d.buy_volume),
|
||||
y: svpData.map(d => d.price),
|
||||
type: 'bar',
|
||||
orientation: 'h',
|
||||
name: 'Buy Volume',
|
||||
marker: { color: 'rgba(78, 205, 196, 0.7)' },
|
||||
hovertemplate: 'Price: $%{y}<br>Buy Volume: $%{customdata:,.0f}<extra></extra>',
|
||||
customdata: svpData.map(d => d.buy_volume)
|
||||
};
|
||||
|
||||
const sellTrace = {
|
||||
x: svpData.map(d => d.sell_volume),
|
||||
y: svpData.map(d => d.price),
|
||||
type: 'bar',
|
||||
orientation: 'h',
|
||||
name: 'Sell Volume',
|
||||
marker: { color: 'rgba(255, 107, 107, 0.7)' },
|
||||
hovertemplate: 'Price: $%{y}<br>Sell Volume: $%{x:,.0f}<extra></extra>'
|
||||
};
|
||||
|
||||
Plotly.redraw('svp-chart', [buyTrace, sellTrace]);
|
||||
}
|
||||
|
||||
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) {
|
||||
// Request fresh data from API
|
||||
fetch(`/api/cob/${encodeURIComponent(currentSymbol)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log('Refreshed data:', data);
|
||||
if (data.data) {
|
||||
handleCOBUpdate({type: 'cob_update', data: data.data});
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Error refreshing data:', error));
|
||||
}
|
||||
}
|
||||
|
||||
// Symbol change handler
|
||||
document.getElementById('symbolSelect').addEventListener('change', function() {
|
||||
currentSymbol = this.value;
|
||||
console.log('Symbol changed to:', currentSymbol);
|
||||
refreshData();
|
||||
});
|
||||
|
||||
// Initialize dashboard
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeCharts();
|
||||
updateStatus('Connecting...', false);
|
||||
|
||||
// Auto-connect on load
|
||||
setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
479
web/cob_realtime_dashboard.py
Normal file
479
web/cob_realtime_dashboard.py
Normal file
@ -0,0 +1,479 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Consolidated Order Book (COB) Real-time Dashboard Server
|
||||
|
||||
Provides a web interface for visualizing:
|
||||
- Consolidated order book across multiple exchanges
|
||||
- Session Volume Profile (SVP) from actual trades
|
||||
- Real-time statistics for neural network models
|
||||
- Hybrid WebSocket + REST API order book data
|
||||
|
||||
Windows-compatible implementation with proper error handling.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import weakref
|
||||
from datetime import datetime, timedelta
|
||||
from collections import deque
|
||||
from typing import Dict, List, Optional, Any
|
||||
import traceback
|
||||
|
||||
# Windows-compatible imports
|
||||
try:
|
||||
from aiohttp import web, WSMsgType
|
||||
import aiohttp_cors
|
||||
except ImportError as e:
|
||||
logging.error(f"Required dependencies missing: {e}")
|
||||
raise
|
||||
|
||||
from core.cob_integration import COBIntegration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class COBDashboardServer:
|
||||
"""
|
||||
Real-time COB Dashboard Server with Windows compatibility
|
||||
"""
|
||||
|
||||
def __init__(self, host: str = 'localhost', port: int = 8053):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.app = web.Application()
|
||||
self.symbols = ['BTC/USDT', 'ETH/USDT']
|
||||
|
||||
# COB components
|
||||
self.cob_integration: Optional[COBIntegration] = None
|
||||
|
||||
# Web server components
|
||||
self.runner = None
|
||||
self.site = None
|
||||
|
||||
# WebSocket connections for real-time updates
|
||||
self.websocket_connections = weakref.WeakSet()
|
||||
|
||||
# Latest data cache for quick serving
|
||||
self.latest_cob_data: Dict[str, Dict] = {}
|
||||
self.latest_stats: Dict = {}
|
||||
|
||||
# Update timestamps for monitoring
|
||||
self.update_timestamps: Dict[str, deque] = {
|
||||
symbol: deque(maxlen=100) for symbol in self.symbols
|
||||
}
|
||||
|
||||
# Setup routes and CORS
|
||||
self._setup_routes()
|
||||
self._setup_cors()
|
||||
|
||||
logger.info(f"COB Dashboard Server initialized for {self.symbols}")
|
||||
|
||||
def _setup_routes(self):
|
||||
"""Setup HTTP routes"""
|
||||
# Static files
|
||||
self.app.router.add_get('/', self.serve_dashboard)
|
||||
|
||||
# API endpoints
|
||||
self.app.router.add_get('/api/symbols', self.get_symbols)
|
||||
self.app.router.add_get('/api/cob/{symbol}', self.get_cob_data)
|
||||
self.app.router.add_get('/api/realtime/{symbol}', self.get_realtime_stats)
|
||||
self.app.router.add_get('/api/status', self.get_status)
|
||||
|
||||
# WebSocket endpoint
|
||||
self.app.router.add_get('/ws', self.websocket_handler)
|
||||
|
||||
def _setup_cors(self):
|
||||
"""Setup CORS for cross-origin requests"""
|
||||
cors = aiohttp_cors.setup(self.app, defaults={
|
||||
"*": aiohttp_cors.ResourceOptions(
|
||||
allow_credentials=True,
|
||||
expose_headers="*",
|
||||
allow_headers="*",
|
||||
allow_methods="*"
|
||||
)
|
||||
})
|
||||
|
||||
# Add CORS to all routes
|
||||
for route in list(self.app.router.routes()):
|
||||
cors.add(route)
|
||||
|
||||
async def start(self):
|
||||
"""Start the dashboard server"""
|
||||
try:
|
||||
logger.info(f"Starting COB Dashboard Server on {self.host}:{self.port}")
|
||||
|
||||
# Start web server first
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
|
||||
self.site = web.TCPSite(self.runner, self.host, self.port)
|
||||
await self.site.start()
|
||||
|
||||
logger.info(f"COB Dashboard Server running at http://{self.host}:{self.port}")
|
||||
|
||||
# Initialize COB integration
|
||||
self.cob_integration = COBIntegration(symbols=self.symbols)
|
||||
self.cob_integration.add_dashboard_callback(self._on_cob_update)
|
||||
|
||||
# Start COB data streaming as background task
|
||||
asyncio.create_task(self.cob_integration.start())
|
||||
|
||||
# Start periodic tasks as background tasks
|
||||
asyncio.create_task(self._periodic_stats_update())
|
||||
asyncio.create_task(self._cleanup_old_data())
|
||||
|
||||
# Keep the server running
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting COB Dashboard Server: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
raise
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the dashboard server"""
|
||||
logger.info("Stopping COB Dashboard Server")
|
||||
|
||||
# Close all WebSocket connections
|
||||
for ws in list(self.websocket_connections):
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error closing WebSocket: {e}")
|
||||
|
||||
# Stop web server
|
||||
if self.site:
|
||||
await self.site.stop()
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
|
||||
# Stop COB integration
|
||||
if self.cob_integration:
|
||||
await self.cob_integration.stop()
|
||||
|
||||
logger.info("COB Dashboard Server stopped")
|
||||
|
||||
async def serve_dashboard(self, request):
|
||||
"""Serve the main dashboard HTML page"""
|
||||
try:
|
||||
return web.FileResponse('web/cob_dashboard.html')
|
||||
except FileNotFoundError:
|
||||
return web.Response(
|
||||
text="Dashboard HTML file not found",
|
||||
status=404,
|
||||
content_type='text/plain'
|
||||
)
|
||||
|
||||
async def get_symbols(self, request):
|
||||
"""Get available symbols"""
|
||||
return web.json_response({
|
||||
'symbols': self.symbols,
|
||||
'default': self.symbols[0] if self.symbols else None
|
||||
})
|
||||
|
||||
async def get_cob_data(self, request):
|
||||
"""Get consolidated order book data for a symbol"""
|
||||
try:
|
||||
symbol = request.match_info['symbol']
|
||||
symbol = symbol.replace('%2F', '/') # URL decode
|
||||
|
||||
if symbol not in self.symbols:
|
||||
return web.json_response({
|
||||
'error': f'Symbol {symbol} not supported',
|
||||
'available_symbols': self.symbols
|
||||
}, status=400)
|
||||
|
||||
# Get latest data from cache or COB integration
|
||||
if symbol in self.latest_cob_data:
|
||||
data = self.latest_cob_data[symbol]
|
||||
elif self.cob_integration:
|
||||
data = await self._generate_dashboard_data(symbol)
|
||||
else:
|
||||
data = self._get_empty_data(symbol)
|
||||
|
||||
return web.json_response({
|
||||
'symbol': symbol,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'data': data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting COB data: {e}")
|
||||
return web.json_response({
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_realtime_stats(self, request):
|
||||
"""Get real-time statistics for neural network models"""
|
||||
try:
|
||||
symbol = request.match_info['symbol']
|
||||
symbol = symbol.replace('%2F', '/')
|
||||
|
||||
if symbol not in self.symbols:
|
||||
return web.json_response({
|
||||
'error': f'Symbol {symbol} not supported'
|
||||
}, status=400)
|
||||
|
||||
stats = {}
|
||||
if self.cob_integration:
|
||||
stats = self.cob_integration.get_realtime_stats_for_nn(symbol)
|
||||
|
||||
return web.json_response({
|
||||
'symbol': symbol,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'stats': stats
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting realtime stats: {e}")
|
||||
return web.json_response({
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def get_status(self, request):
|
||||
"""Get server status"""
|
||||
status = {
|
||||
'server': 'running',
|
||||
'symbols': self.symbols,
|
||||
'websocket_connections': len(self.websocket_connections),
|
||||
'cob_integration': 'active' if self.cob_integration else 'inactive',
|
||||
'last_updates': {}
|
||||
}
|
||||
|
||||
# Add last update times
|
||||
for symbol in self.symbols:
|
||||
if symbol in self.update_timestamps and self.update_timestamps[symbol]:
|
||||
status['last_updates'][symbol] = self.update_timestamps[symbol][-1].isoformat()
|
||||
|
||||
return web.json_response(status)
|
||||
|
||||
async def websocket_handler(self, request):
|
||||
"""Handle WebSocket connections"""
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
|
||||
# Add to connections
|
||||
self.websocket_connections.add(ws)
|
||||
logger.info(f"WebSocket connected. Total connections: {len(self.websocket_connections)}")
|
||||
|
||||
try:
|
||||
# Send initial data
|
||||
for symbol in self.symbols:
|
||||
if symbol in self.latest_cob_data:
|
||||
await self._send_websocket_data(ws, 'cob_update', symbol, self.latest_cob_data[symbol])
|
||||
|
||||
# Handle incoming messages
|
||||
async for msg in ws:
|
||||
if msg.type == WSMsgType.TEXT:
|
||||
try:
|
||||
data = json.loads(msg.data)
|
||||
await self._handle_websocket_message(ws, data)
|
||||
except json.JSONDecodeError:
|
||||
await ws.send_str(json.dumps({
|
||||
'type': 'error',
|
||||
'message': 'Invalid JSON'
|
||||
}))
|
||||
elif msg.type == WSMsgType.ERROR:
|
||||
logger.error(f'WebSocket error: {ws.exception()}')
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket error: {e}")
|
||||
|
||||
finally:
|
||||
# Remove from connections
|
||||
self.websocket_connections.discard(ws)
|
||||
logger.info(f"WebSocket disconnected. Remaining connections: {len(self.websocket_connections)}")
|
||||
|
||||
return ws
|
||||
|
||||
async def _handle_websocket_message(self, ws, data):
|
||||
"""Handle incoming WebSocket messages"""
|
||||
try:
|
||||
message_type = data.get('type')
|
||||
|
||||
if message_type == 'subscribe':
|
||||
symbol = data.get('symbol')
|
||||
if symbol in self.symbols and symbol in self.latest_cob_data:
|
||||
await self._send_websocket_data(ws, 'cob_update', symbol, self.latest_cob_data[symbol])
|
||||
|
||||
elif message_type == 'ping':
|
||||
await ws.send_str(json.dumps({
|
||||
'type': 'pong',
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling WebSocket message: {e}")
|
||||
|
||||
async def _on_cob_update(self, symbol: str, data: Dict):
|
||||
"""Handle COB updates from integration"""
|
||||
try:
|
||||
logger.debug(f"Received COB update for {symbol}")
|
||||
|
||||
# Update cache
|
||||
self.latest_cob_data[symbol] = data
|
||||
self.update_timestamps[symbol].append(datetime.now())
|
||||
|
||||
# Broadcast to WebSocket clients
|
||||
await self._broadcast_cob_update(symbol, data)
|
||||
|
||||
logger.debug(f"Broadcasted COB update for {symbol} to {len(self.websocket_connections)} connections")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling COB 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
|
||||
|
||||
message = {
|
||||
'type': 'cob_update',
|
||||
'symbol': symbol,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'data': data
|
||||
}
|
||||
|
||||
# Send to all connections
|
||||
dead_connections = []
|
||||
for ws in self.websocket_connections:
|
||||
try:
|
||||
await ws.send_str(json.dumps(message))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send to WebSocket: {e}")
|
||||
dead_connections.append(ws)
|
||||
|
||||
# Clean up dead connections
|
||||
for ws in dead_connections:
|
||||
self.websocket_connections.discard(ws)
|
||||
|
||||
async def _send_websocket_data(self, ws, msg_type: str, symbol: str, data: Dict):
|
||||
"""Send data to a specific WebSocket connection"""
|
||||
try:
|
||||
message = {
|
||||
'type': msg_type,
|
||||
'symbol': symbol,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'data': data
|
||||
}
|
||||
await ws.send_str(json.dumps(message))
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending WebSocket data: {e}")
|
||||
|
||||
async def _generate_dashboard_data(self, symbol: str) -> Dict:
|
||||
"""Generate dashboard data for a symbol"""
|
||||
try:
|
||||
# Return cached data from COB integration callbacks
|
||||
if symbol in self.latest_cob_data:
|
||||
return self.latest_cob_data[symbol]
|
||||
else:
|
||||
return self._get_empty_data(symbol)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating dashboard data for {symbol}: {e}")
|
||||
return self._get_empty_data(symbol)
|
||||
|
||||
def _get_empty_data(self, symbol: str) -> Dict:
|
||||
"""Get empty data structure"""
|
||||
return {
|
||||
'symbol': symbol,
|
||||
'bids': [],
|
||||
'asks': [],
|
||||
'svp': {'data': []},
|
||||
'stats': {
|
||||
'mid_price': 0,
|
||||
'spread_bps': 0,
|
||||
'bid_liquidity': 0,
|
||||
'ask_liquidity': 0,
|
||||
'bid_levels': 0,
|
||||
'ask_levels': 0,
|
||||
'imbalance': 0
|
||||
}
|
||||
}
|
||||
|
||||
async def _periodic_stats_update(self):
|
||||
"""Periodically update and broadcast statistics"""
|
||||
while True:
|
||||
try:
|
||||
# Calculate update frequencies
|
||||
update_frequencies = {}
|
||||
for symbol in self.symbols:
|
||||
if symbol in self.update_timestamps and len(self.update_timestamps[symbol]) > 1:
|
||||
timestamps = list(self.update_timestamps[symbol])
|
||||
if len(timestamps) >= 2:
|
||||
time_diff = (timestamps[-1] - timestamps[-2]).total_seconds()
|
||||
if time_diff > 0:
|
||||
update_frequencies[symbol] = 1.0 / time_diff
|
||||
|
||||
# Broadcast stats if needed
|
||||
if update_frequencies:
|
||||
stats_message = {
|
||||
'type': 'stats_update',
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'update_frequencies': update_frequencies
|
||||
}
|
||||
|
||||
for ws in list(self.websocket_connections):
|
||||
try:
|
||||
await ws.send_str(json.dumps(stats_message))
|
||||
except Exception:
|
||||
self.websocket_connections.discard(ws)
|
||||
|
||||
await asyncio.sleep(5) # Update every 5 seconds
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in periodic stats update: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _cleanup_old_data(self):
|
||||
"""Clean up old data to prevent memory leaks"""
|
||||
while True:
|
||||
try:
|
||||
cutoff_time = datetime.now() - timedelta(hours=1)
|
||||
|
||||
# Clean up old timestamps
|
||||
for symbol in self.symbols:
|
||||
if symbol in self.update_timestamps:
|
||||
timestamps = self.update_timestamps[symbol]
|
||||
while timestamps and timestamps[0] < cutoff_time:
|
||||
timestamps.popleft()
|
||||
|
||||
await asyncio.sleep(300) # Clean up every 5 minutes
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in cleanup: {e}")
|
||||
await asyncio.sleep(300)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point"""
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
logger.info("Starting COB Dashboard Server")
|
||||
|
||||
try:
|
||||
# Windows event loop policy fix
|
||||
if hasattr(asyncio, 'WindowsProactorEventLoopPolicy'):
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
|
||||
server = COBDashboardServer()
|
||||
await server.start()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("COB Dashboard Server interrupted by user")
|
||||
except Exception as e:
|
||||
logger.error(f"COB Dashboard Server failed: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
finally:
|
||||
if 'server' in locals():
|
||||
await server.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
Reference in New Issue
Block a user