wip COB data source - not ready

This commit is contained in:
Dobromir Popov
2025-08-08 00:49:13 +03:00
parent 0ce6e2691b
commit bd15bdc87d
7 changed files with 327 additions and 166 deletions

View File

@ -91,3 +91,14 @@ also, adjust our bybit api so we trade with usdt futures - where we can have up
--------------
1. on the dash buy/sell buttons do not open/close positions in live mode .
2. we also need to fix our Current Order Book data shown on the dash - it is not consistent ande definitely not fast/low latency. let's store all COB data aggregated to 1S buckets and 0.2s sec ticks. show COB datasource updte rate
3. we don't calculate the COB imbalance correctly - we have MA with 4 time windows.
4. we have some more work on the models statistics and overview but we can focust there later when we fix the other issues
5. audit and backtest if calculate_williams_pivot_points works correctly. show pivot points on the dash on the 1m candlesticks

View File

@ -1137,8 +1137,19 @@ class DataProvider:
# Extract timestamp and price from tick
if isinstance(tick, dict):
timestamp = tick.get('timestamp')
price = tick.get('price', tick.get('mid_price', 0))
volume = tick.get('volume', 1.0) # Default volume if not available
# Prefer explicit price if available, fallback to stats.mid_price
stats = tick.get('stats', {}) if isinstance(tick.get('stats', {}), dict) else {}
price = tick.get('price')
if not price:
price = tick.get('mid_price') or stats.get('mid_price', 0)
# Derive a volume proxy if not provided (use bid+ask volume from stats)
volume = tick.get('volume')
if volume is None:
bid_vol = stats.get('bid_volume', 0) or 0
ask_vol = stats.get('ask_volume', 0) or 0
volume = float(bid_vol) + float(ask_vol)
if volume == 0:
volume = 1.0 # Minimal placeholder to avoid zero-volume bars
else:
continue
@ -2221,14 +2232,40 @@ class DataProvider:
# Get latest COB data from cache
cob_data = self.get_latest_cob_data(symbol)
if cob_data and 'current_price' in cob_data:
if cob_data:
# Determine current price (prefer explicit field, fallback to stats.mid_price)
stats = cob_data.get('stats', {}) if isinstance(cob_data.get('stats', {}), dict) else {}
current_price = cob_data.get('current_price') or stats.get('mid_price', 0.0)
bucket_size = 1.0 if 'ETH' in symbol else 10.0
# Ensure price buckets exist; compute from bids/asks if missing
price_buckets = cob_data.get('price_buckets') or {}
if (not price_buckets) and current_price:
price_buckets = self._compute_price_buckets_from_snapshot(
current_price=current_price,
bucket_size=bucket_size,
bids=cob_data.get('bids', []),
asks=cob_data.get('asks', [])
)
# Build imbalance map (price -> imbalance) if not provided
bid_ask_imbalance = cob_data.get('bid_ask_imbalance') or {}
if not bid_ask_imbalance and price_buckets:
tmp = {}
for price, bucket in price_buckets.items():
bid_vol = float(bucket.get('bid_volume', 0.0) or 0.0)
ask_vol = float(bucket.get('ask_volume', 0.0) or 0.0)
denom = bid_vol + ask_vol
tmp[price] = (bid_vol - ask_vol) / denom if denom > 0 else 0.0
bid_ask_imbalance = tmp
return COBData(
symbol=symbol,
timestamp=datetime.now(),
current_price=cob_data['current_price'],
bucket_size=1.0 if 'ETH' in symbol else 10.0,
price_buckets=cob_data.get('price_buckets', {}),
bid_ask_imbalance=cob_data.get('bid_ask_imbalance', {}),
current_price=float(current_price or 0.0),
bucket_size=bucket_size,
price_buckets=price_buckets,
bid_ask_imbalance=bid_ask_imbalance,
volume_weighted_prices=cob_data.get('volume_weighted_prices', {}),
order_flow_metrics=cob_data.get('order_flow_metrics', {}),
ma_1s_imbalance=cob_data.get('ma_1s_imbalance', {}),
@ -2241,6 +2278,67 @@ class DataProvider:
logger.error(f"Error getting COB data object for {symbol}: {e}")
return None
def _compute_price_buckets_from_snapshot(
self,
current_price: float,
bucket_size: float,
bids: List[List[float]],
asks: List[List[float]]
) -> Dict[float, Dict[str, float]]:
"""Compute ±20 price buckets around current price from raw bids/asks.
Returns dict: price -> {bid_volume, ask_volume, total_volume, imbalance}
"""
try:
# Initialize bucket map for ±20 buckets
bucket_map: Dict[float, Dict[str, float]] = {}
if not current_price or bucket_size <= 0:
return bucket_map
# Center-aligned bucket prices
bucket_count = 20
for i in range(-bucket_count, bucket_count + 1):
price = (round(current_price / bucket_size) * bucket_size) + (i * bucket_size)
bucket_map[price] = {
'bid_volume': 0.0,
'ask_volume': 0.0,
'total_volume': 0.0,
'imbalance': 0.0,
}
# Aggregate bids
for level in (bids or [])[:200]:
try:
price, size = float(level[0]), float(level[1])
except Exception:
continue
bucket_price = round(price / bucket_size) * bucket_size
if bucket_price in bucket_map:
bucket_map[bucket_price]['bid_volume'] += size
# Aggregate asks
for level in (asks or [])[:200]:
try:
price, size = float(level[0]), float(level[1])
except Exception:
continue
bucket_price = round(price / bucket_size) * bucket_size
if bucket_price in bucket_map:
bucket_map[bucket_price]['ask_volume'] += size
# Compute totals and imbalance
for price, bucket in bucket_map.items():
bid_vol = float(bucket['bid_volume'])
ask_vol = float(bucket['ask_volume'])
total = bid_vol + ask_vol
bucket['total_volume'] = total
bucket['imbalance'] = (bid_vol - ask_vol) / total if total > 0 else 0.0
return bucket_map
except Exception as e:
logger.debug(f"Error computing price buckets: {e}")
return {}
def _add_basic_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
@ -4278,13 +4376,46 @@ class DataProvider:
if symbol not in self.cob_data_cache:
self.cob_data_cache[symbol] = []
# Convert WebSocket format to standard format
# Convert WebSocket format to standard format and enrich stats if missing
bids_arr = [[bid['price'], bid['size']] for bid in cob_data.get('bids', [])[:50]]
asks_arr = [[ask['price'], ask['size']] for ask in cob_data.get('asks', [])[:50]]
stats_in = cob_data.get('stats', {}) if isinstance(cob_data.get('stats', {}), dict) else {}
# Derive stats when not provided by source
best_bid = max([b[0] for b in bids_arr], default=0)
best_ask = min([a[0] for a in asks_arr], default=0)
mid = stats_in.get('mid_price') or ((best_bid + best_ask) / 2.0 if best_bid > 0 and best_ask > 0 else 0)
total_bid_liq = sum([b[0] * b[1] for b in bids_arr]) # price*size USD approx
total_ask_liq = sum([a[0] * a[1] for a in asks_arr])
spread_bps = 0
if best_bid > 0 and best_ask > 0 and mid > 0:
spread_bps = ((best_ask - best_bid) / mid) * 10000
imbalance = 0.0
denom = (total_bid_liq + total_ask_liq)
if denom > 0:
imbalance = (total_bid_liq - total_ask_liq) / denom
stats_out = {
'mid_price': mid,
'spread_bps': spread_bps,
'imbalance': imbalance,
'best_bid': best_bid,
'best_ask': best_ask,
'bid_volume': total_bid_liq,
'ask_volume': total_ask_liq,
'bid_levels': len(bids_arr),
'ask_levels': len(asks_arr)
}
# Merge any provided stats atop computed defaults
stats_out.update(stats_in or {})
standard_cob_data = {
'symbol': symbol,
'timestamp': int(cob_data['timestamp'] * 1000), # Convert to milliseconds
'bids': [[bid['price'], bid['size']] for bid in cob_data.get('bids', [])[:50]],
'asks': [[ask['price'], ask['size']] for ask in cob_data.get('asks', [])[:50]],
'stats': cob_data.get('stats', {})
'bids': bids_arr,
'asks': asks_arr,
'stats': stats_out
}
# Add to cache

View File

@ -99,6 +99,7 @@ class ExchangeType(Enum):
KRAKEN = "kraken"
HUOBI = "huobi"
BITFINEX = "bitfinex"
COINAPI = "coinapi"
@dataclass
class ExchangeOrderBookLevel:

View File

@ -86,6 +86,15 @@ class StandardizedDataProvider(DataProvider):
enabled=True,
websocket_url="wss://stream.binance.com:9443/ws/",
symbols_mapping={symbol: symbol.replace('/', '').lower() for symbol in self.symbols}
),
# CoinAPI REST for supplemental depth snapshots (merged with WS streams)
'coinapi': ExchangeConfig(
exchange_type=ExchangeType.COINAPI,
weight=0.6,
enabled=True,
rest_api_url="https://rest.coinapi.io/v1/",
symbols_mapping={symbol: symbol.replace('/', '_').replace('USDT', 'USD') for symbol in self.symbols},
rate_limits={"min_interval_ms": 500}
)
}
@ -229,69 +238,24 @@ class StandardizedDataProvider(DataProvider):
COBData: COB data with price buckets and moving averages
"""
try:
if not self.cob_provider:
# Use real-time COB snapshot from parent and convert to COBData
cob_obj = self._get_latest_cob_data_object(symbol)
if cob_obj is None:
return None
# Get current price
current_price = self.current_prices.get(symbol.replace('/', '').upper(), 0.0)
if current_price <= 0:
return None
# Determine bucket size based on symbol
bucket_size = 1.0 if 'ETH' in symbol else 10.0 # $1 for ETH, $10 for BTC
# Calculate price range (±20 buckets)
price_range = 20 * bucket_size
min_price = current_price - price_range
max_price = current_price + price_range
# Create price buckets
price_buckets = {}
bid_ask_imbalance = {}
volume_weighted_prices = {}
# Generate mock COB data for now (will be replaced with real COB provider data)
for i in range(-20, 21):
price = current_price + (i * bucket_size)
if price > 0:
# Mock data - replace with real COB provider data
bid_volume = max(0, 1000 - abs(i) * 50) # More volume near current price
ask_volume = max(0, 1000 - abs(i) * 50)
total_volume = bid_volume + ask_volume
imbalance = (bid_volume - ask_volume) / max(total_volume, 1)
price_buckets[price] = {
'bid_volume': bid_volume,
'ask_volume': ask_volume,
'total_volume': total_volume,
'imbalance': imbalance
}
bid_ask_imbalance[price] = imbalance
volume_weighted_prices[price] = price # Simplified VWAP
# Calculate moving averages of imbalance for ±5 buckets
ma_data = self._calculate_cob_moving_averages(symbol, bid_ask_imbalance, timestamp)
cob_data = COBData(
symbol=symbol,
timestamp=timestamp,
current_price=current_price,
bucket_size=bucket_size,
price_buckets=price_buckets,
bid_ask_imbalance=bid_ask_imbalance,
volume_weighted_prices=volume_weighted_prices,
order_flow_metrics={},
ma_1s_imbalance=ma_data.get('1s', {}),
ma_5s_imbalance=ma_data.get('5s', {}),
ma_15s_imbalance=ma_data.get('15s', {}),
ma_60s_imbalance=ma_data.get('60s', {})
)
# Cache the COB data
self.cob_data_cache[symbol] = cob_data
return cob_data
ma_data = self._calculate_cob_moving_averages(symbol, cob_obj.bid_ask_imbalance, timestamp)
# Update MA fields
cob_obj.ma_1s_imbalance = ma_data.get('1s', {})
cob_obj.ma_5s_imbalance = ma_data.get('5s', {})
cob_obj.ma_15s_imbalance = ma_data.get('15s', {})
cob_obj.ma_60s_imbalance = ma_data.get('60s', {})
# Cache and return
self.cob_data_cache[symbol] = cob_obj
return cob_obj
except Exception as e:
logger.error(f"Error getting COB data for {symbol}: {e}")
return None
@ -379,16 +343,40 @@ class StandardizedDataProvider(DataProvider):
def _get_pivot_points(self, symbol: str) -> List[PivotPoint]:
"""Get pivot points for a symbol"""
try:
pivot_points = []
# Get pivot points from Williams Market Structure if available
if symbol in self.williams_structure:
williams = self.williams_structure[symbol]
# This would need to be implemented based on the actual Williams structure
# For now, return empty list
pass
return pivot_points
results: List[PivotPoint] = []
# Prefer DataProvider's Williams calculation (1s OHLCV based)
try:
levels = self.calculate_williams_pivot_points(symbol)
except Exception:
levels = {}
# Flatten levels into standardized PivotPoint list
if levels:
for level_idx, trend_level in levels.items():
# Expect trend_level to have an iterable of pivot points
pivots = getattr(trend_level, 'pivots', None)
if not pivots:
# Some implementations may expose as list directly
pivots = getattr(trend_level, 'points', [])
for p in pivots or []:
# Map fields defensively
ts = getattr(p, 'timestamp', None)
price = float(getattr(p, 'price', 0.0) or 0.0)
ptype = getattr(p, 'pivot_type', getattr(p, 'type', 'low'))
ptype = 'high' if str(ptype).lower() == 'high' else 'low'
lvl = int(getattr(p, 'level', level_idx) or level_idx)
if ts and price > 0:
results.append(PivotPoint(
symbol=symbol,
timestamp=ts,
price=price,
type=ptype,
level=lvl,
confidence=1.0
))
return results
except Exception as e:
logger.error(f"Error getting pivot points for {symbol}: {e}")

10
main.py
View File

@ -51,12 +51,12 @@ async def run_web_dashboard():
config = get_config()
# Initialize core components for streamlined pipeline
from core.data_provider import DataProvider
from core.standardized_data_provider import StandardizedDataProvider
from core.orchestrator import TradingOrchestrator
from core.trading_executor import TradingExecutor
# Create data provider
data_provider = DataProvider()
# Create standardized data provider (validated BaseDataInput, pivots, COB)
data_provider = StandardizedDataProvider()
# Start real-time streaming for BOM caching
try:
@ -153,13 +153,13 @@ def start_web_ui(port=8051):
# Import and create the Clean Trading Dashboard
from web.clean_dashboard import CleanTradingDashboard
from core.data_provider import DataProvider
from core.standardized_data_provider import StandardizedDataProvider
from core.orchestrator import TradingOrchestrator
from core.trading_executor import TradingExecutor
# Initialize components for the dashboard
config = get_config()
data_provider = DataProvider()
data_provider = StandardizedDataProvider()
# Start real-time streaming for BOM caching (non-blocking)
try:

View File

@ -1300,7 +1300,7 @@ class CleanTradingDashboard:
"""Update COB data displays with real order book ladders and cumulative stats"""
try:
# COB data is critical for trading - keep at 2s interval
import time
eth_snapshot = self._get_cob_snapshot('ETH/USDT')
btc_snapshot = self._get_cob_snapshot('BTC/USDT')
@ -1352,8 +1352,47 @@ class CleanTradingDashboard:
if isinstance(btc_snapshot, list):
btc_snapshot = None
eth_components = self.component_manager.format_cob_data(eth_snapshot, 'ETH/USDT', eth_imbalance_stats, cob_mode)
btc_components = self.component_manager.format_cob_data(btc_snapshot, 'BTC/USDT', btc_imbalance_stats, cob_mode)
# Compute and display COB update rate and include recent aggregated views
def _calc_update_rate(symbol):
if not hasattr(self, 'cob_last_update'):
return "n/a"
last_ts = self.cob_last_update.get(symbol)
if not last_ts:
return "n/a"
age = time.time() - last_ts
if age <= 0:
return "n/a"
hz = 1.0 / age if age > 0 else 0
return f"{hz:.1f} Hz"
# Fetch aggregated 1s COB and recent ~0.2s ticks
def _recent_ticks(symbol):
if hasattr(self.data_provider, 'get_cob_raw_ticks'):
ticks = self.data_provider.get_cob_raw_ticks(symbol, count=25)
return ticks[-5:] if ticks else []
return []
eth_rate = _calc_update_rate('ETH/USDT')
btc_rate = _calc_update_rate('BTC/USDT')
eth_agg_1s = self.data_provider.get_cob_1s_aggregated('ETH/USDT') if hasattr(self.data_provider, 'get_cob_1s_aggregated') else []
btc_agg_1s = self.data_provider.get_cob_1s_aggregated('BTC/USDT') if hasattr(self.data_provider, 'get_cob_1s_aggregated') else []
eth_recent = _recent_ticks('ETH/USDT')
btc_recent = _recent_ticks('BTC/USDT')
eth_components = self.component_manager.format_cob_data(
eth_snapshot,
'ETH/USDT',
eth_imbalance_stats,
cob_mode,
update_info={'update_rate': eth_rate, 'aggregated_1s': eth_agg_1s[-5:], 'recent_ticks': eth_recent}
)
btc_components = self.component_manager.format_cob_data(
btc_snapshot,
'BTC/USDT',
btc_imbalance_stats,
cob_mode,
update_info={'update_rate': btc_rate, 'aggregated_1s': btc_agg_1s[-5:], 'recent_ticks': btc_recent}
)
return eth_components, btc_components
@ -3427,23 +3466,28 @@ class CleanTradingDashboard:
def _get_cob_mode(self) -> str:
"""Get current COB data collection mode"""
try:
# Check if data provider has WebSocket COB integration
if self.data_provider and hasattr(self.data_provider, 'cob_websocket'):
# Check WebSocket status
if hasattr(self.data_provider.cob_websocket, 'status'):
eth_status = self.data_provider.cob_websocket.status.get('ETH/USDT')
if eth_status and eth_status.connected:
return "WS" # WebSocket mode
# Check if we have recent WebSocket data
if hasattr(self.data_provider, 'cob_raw_ticks'):
eth_ticks = self.data_provider.cob_raw_ticks.get('ETH/USDT', [])
if eth_ticks:
import time
latest_tick = eth_ticks[-1]
tick_time = latest_tick.get('timestamp', 0)
if isinstance(tick_time, (int, float)) and (time.time() - tick_time) < 10:
return "WS" # Recent WebSocket data
# Determine WS mode using provider's enhanced websocket or raw tick recency
if self.data_provider:
# Preferred: enhanced websocket status summary
if hasattr(self.data_provider, 'get_cob_websocket_status'):
try:
status = self.data_provider.get_cob_websocket_status()
overall = status.get('overall_status') or status.get('status')
if overall in ("active", "ok", "ready"):
return "WS"
except Exception:
pass
# Fallback: raw ticks recency
if hasattr(self.data_provider, 'get_cob_raw_ticks'):
try:
ticks = self.data_provider.get_cob_raw_ticks('ETH/USDT', count=1)
if ticks:
import time
t = ticks[-1].get('timestamp', 0)
if isinstance(t, (int, float)) and (time.time() - t) < 5:
return "WS"
except Exception:
pass
# Check if we have any COB data (REST fallback)
if hasattr(self, 'latest_cob_data') and 'ETH/USDT' in self.latest_cob_data:
@ -7193,9 +7237,8 @@ class CleanTradingDashboard:
if hasattr(self.data_provider, 'get_base_data_input'):
return self.data_provider.get_base_data_input(symbol)
# Fallback: create BaseDataInput from available data
from core.data_models import BaseDataInput, OHLCVBar, COBData
import random
# Fallback: create BaseDataInput from available data (no synthetic data)
from core.data_models import BaseDataInput
# Get OHLCV data for different timeframes - ensure we have enough data
ohlcv_1s = self._get_ohlcv_bars(symbol, '1s', 300)
@ -7206,53 +7249,11 @@ class CleanTradingDashboard:
# Get BTC reference data
btc_ohlcv_1s = self._get_ohlcv_bars('BTC/USDT', '1s', 300)
# Ensure we have minimum required data (pad if necessary)
def pad_ohlcv_data(bars, target_count=300):
if len(bars) < target_count:
# Pad with realistic variation instead of identical bars
if len(bars) > 0:
last_bar = bars[-1]
# Add small random variation to prevent identical data
for i in range(target_count - len(bars)):
# Create slight variations of the last bar
variation = random.uniform(-0.001, 0.001) # 0.1% variation
new_bar = OHLCVBar(
symbol=last_bar.symbol,
timestamp=last_bar.timestamp + timedelta(seconds=i),
open=last_bar.open * (1 + variation),
high=last_bar.high * (1 + variation),
low=last_bar.low * (1 + variation),
close=last_bar.close * (1 + variation),
volume=last_bar.volume * (1 + random.uniform(-0.1, 0.1)),
timeframe=last_bar.timeframe
)
bars.append(new_bar)
else:
# Create realistic dummy bars with variation
base_price = 3500.0
for i in range(target_count):
# Add realistic price movement
price_change = random.uniform(-0.02, 0.02) # 2% max change
current_price = base_price * (1 + price_change)
dummy_bar = OHLCVBar(
symbol=symbol,
timestamp=datetime.now() - timedelta(seconds=target_count-i),
open=current_price * random.uniform(0.998, 1.002),
high=current_price * random.uniform(1.000, 1.005),
low=current_price * random.uniform(0.995, 1.000),
close=current_price,
volume=random.uniform(500.0, 2000.0),
timeframe="1s"
)
bars.append(dummy_bar)
return bars[:target_count] # Ensure exactly target_count
# Pad all data to required length
ohlcv_1s = pad_ohlcv_data(ohlcv_1s, 300)
ohlcv_1m = pad_ohlcv_data(ohlcv_1m, 300)
ohlcv_1h = pad_ohlcv_data(ohlcv_1h, 300)
ohlcv_1d = pad_ohlcv_data(ohlcv_1d, 300)
btc_ohlcv_1s = pad_ohlcv_data(btc_ohlcv_1s, 300)
# Strictly require sufficient real data; otherwise return None
datasets = [ohlcv_1s, ohlcv_1m, ohlcv_1h, ohlcv_1d, btc_ohlcv_1s]
if any(len(ds) < 100 for ds in datasets):
logger.warning(f"Insufficient real OHLCV data for {symbol}; skipping BaseDataInput fallback")
return None
logger.debug(f"OHLCV data lengths: 1s={len(ohlcv_1s)}, 1m={len(ohlcv_1m)}, 1h={len(ohlcv_1h)}, 1d={len(ohlcv_1d)}, BTC={len(btc_ohlcv_1s)}")

View File

@ -296,14 +296,17 @@ class DashboardComponentManager:
logger.error(f"Error formatting system status: {e}")
return [html.P(f"Error: {str(e)}", className="text-danger small")]
def format_cob_data(self, cob_snapshot, symbol, cumulative_imbalance_stats=None, cob_mode="Unknown"):
"""Format COB data into a split view with summary, imbalance stats, and a compact ladder."""
def format_cob_data(self, cob_snapshot, symbol, cumulative_imbalance_stats=None, cob_mode="Unknown", update_info: dict = None):
"""Format COB data into a split view with summary, imbalance stats, and a compact ladder.
update_info can include keys: 'update_rate', 'aggregated_1s', 'recent_ticks'.
"""
try:
if not cob_snapshot:
return html.Div([
html.H6(f"{symbol} COB", className="mb-2"),
html.P("No COB data available", className="text-muted small"),
html.P(f"Mode: {cob_mode}", className="text-muted small")
html.P(f"Mode: {cob_mode}", className="text-muted small"),
html.P(f"Update: {(update_info or {}).get('update_rate', 'n/a')}", className="text-muted small")
])
# Defensive: If cob_snapshot is a list, log and return error
@ -312,7 +315,8 @@ class DashboardComponentManager:
return html.Div([
html.H6(f"{symbol} COB", className="mb-2"),
html.P("Invalid COB data format (list)", className="text-danger small"),
html.P(f"Mode: {cob_mode}", className="text-muted small")
html.P(f"Mode: {cob_mode}", className="text-muted small"),
html.P(f"Update: {(update_info or {}).get('update_rate', 'n/a')}", className="text-muted small")
])
# Debug: Log the type and structure of cob_snapshot
@ -347,7 +351,9 @@ class DashboardComponentManager:
if mid_price == 0 or not bids or not asks:
return html.Div([
html.H6(f"{symbol} COB", className="mb-2"),
html.P("Awaiting valid order book data...", className="text-muted small")
html.P("Awaiting valid order book data...", className="text-muted small"),
html.P(f"Mode: {cob_mode}", className="text-muted small"),
html.P(f"Update: {(update_info or {}).get('update_rate', 'n/a')}", className="text-muted small")
])
# Create stats dict for compatibility with existing code
@ -362,15 +368,38 @@ class DashboardComponentManager:
}
# --- Left Panel: Overview and Stats ---
# Prepend update info to overview
overview_panel = self._create_cob_overview_panel(symbol, stats, cumulative_imbalance_stats, cob_mode)
if update_info and update_info.get('update_rate'):
# Wrap with a small header line for update rate
overview_panel = html.Div([
html.Div(html.Small(f"Update: {update_info['update_rate']}", className="text-muted"), className="mb-1"),
overview_panel
])
# --- Right Panel: Compact Ladder ---
ladder_panel = self._create_cob_ladder_panel(bids, asks, mid_price, symbol)
# Append small extras line from aggregated_1s and recent_ticks
extras = []
if update_info:
agg = (update_info.get('aggregated_1s') or [])
if agg and isinstance(agg[-1], dict):
last = agg[-1]
avg_spread = last.get('spread', {}).get('average_bps', 0)
avg_imb = last.get('imbalance', {}).get('average', 0)
tick_count = last.get('tick_count', 0)
extras.append(html.Small(f"1s agg: {tick_count} ticks, spread {avg_spread:.1f} bps, imb {avg_imb:.2f}", className="text-muted"))
recent = (update_info.get('recent_ticks') or [])
if recent:
extras.append(html.Small(f"Recent ticks: {len(recent)}", className="text-muted ms-2"))
extras_div = html.Div(extras, className="mb-1") if extras else None
return dbc.Row([
dbc.Col(overview_panel, width=5, className="pe-1"),
dbc.Col(ladder_panel, width=7, className="ps-1")
], className="g-0") # g-0 removes gutters
children = [dbc.Col(overview_panel, width=5, className="pe-1")]
right_children = [ladder_panel]
if extras_div:
right_children.insert(0, extras_div)
children.append(dbc.Col(html.Div(right_children), width=7, className="ps-1"))
return dbc.Row(children, className="g-0") # g-0 removes gutters
except Exception as e:
logger.error(f"Error formatting split COB data: {e}")