diff --git a/realtime.py b/realtime.py
index 60186c4..df41f6b 100644
--- a/realtime.py
+++ b/realtime.py
@@ -31,14 +31,22 @@ class TradeTickStorage:
def __init__(self, max_age_seconds: int = 300): # 5 minutes by default
self.ticks = []
self.max_age_seconds = max_age_seconds
+ self.last_cleanup_time = time.time()
+ self.cleanup_interval = 10 # Run cleanup every 10 seconds to avoid doing it on every tick
+ self.last_tick = None
logger.info(f"Initialized TradeTickStorage with max age: {max_age_seconds} seconds")
def add_tick(self, tick: Dict):
"""Add a new trade tick to storage"""
self.ticks.append(tick)
+ self.last_tick = tick
logger.debug(f"Added tick: {tick}, total ticks: {len(self.ticks)}")
- # Clean up old ticks
- self._cleanup()
+
+ # Only clean up periodically rather than on every tick
+ current_time = time.time()
+ if current_time - self.last_cleanup_time > self.cleanup_interval:
+ self._cleanup()
+ self.last_cleanup_time = current_time
def _cleanup(self):
"""Remove ticks older than max_age_seconds"""
@@ -46,8 +54,42 @@ class TradeTickStorage:
cutoff = now - (self.max_age_seconds * 1000)
old_count = len(self.ticks)
self.ticks = [tick for tick in self.ticks if tick['timestamp'] >= cutoff]
- if old_count > len(self.ticks):
- logger.debug(f"Cleaned up {old_count - len(self.ticks)} old ticks")
+ removed = old_count - len(self.ticks)
+ if removed > 0:
+ logger.debug(f"Cleaned up {removed} old ticks, remaining: {len(self.ticks)}")
+
+ def get_latest_price(self) -> Optional[float]:
+ """Get the latest price from the most recent tick"""
+ if self.last_tick:
+ return self.last_tick.get('price')
+ elif self.ticks:
+ # If last_tick not available but ticks exist, use the last tick
+ self.last_tick = self.ticks[-1]
+ return self.last_tick.get('price')
+ return None
+
+ def get_price_stats(self) -> Dict:
+ """Get stats about the prices in storage"""
+ if not self.ticks:
+ return {
+ 'min': None,
+ 'max': None,
+ 'latest': None,
+ 'count': 0,
+ 'age_seconds': 0
+ }
+
+ prices = [tick['price'] for tick in self.ticks]
+ latest_timestamp = self.ticks[-1]['timestamp']
+ oldest_timestamp = self.ticks[0]['timestamp']
+
+ return {
+ 'min': min(prices),
+ 'max': max(prices),
+ 'latest': prices[-1],
+ 'count': len(prices),
+ 'age_seconds': (latest_timestamp - oldest_timestamp) / 1000
+ }
def get_ticks_as_df(self) -> pd.DataFrame:
"""Return ticks as a DataFrame"""
@@ -55,6 +97,9 @@ class TradeTickStorage:
logger.warning("No ticks available for DataFrame conversion")
return pd.DataFrame()
+ # Ensure we have fresh data
+ self._cleanup()
+
df = pd.DataFrame(self.ticks)
if not df.empty:
logger.debug(f"Converting timestamps for {len(df)} ticks")
@@ -485,41 +530,111 @@ class RealTimeChart:
self.app = dash.Dash(__name__)
self.candlestick_data = CandlestickData()
self.tick_storage = TradeTickStorage(max_age_seconds=300) # Store 5 minutes of ticks
+ self.ohlcv_cache = { # Cache for different intervals
+ '1s': None,
+ '1m': None,
+ '1h': None,
+ '1d': None
+ }
logger.info(f"Initializing RealTimeChart for {symbol}")
+ # Button style
+ button_style = {
+ 'background-color': '#4CAF50',
+ 'color': 'white',
+ 'padding': '10px 15px',
+ 'margin': '5px',
+ 'border': 'none',
+ 'border-radius': '5px',
+ 'font-size': '14px',
+ 'cursor': 'pointer',
+ 'transition': 'background-color 0.3s',
+ 'font-weight': 'bold'
+ }
+
+ active_button_style = {
+ **button_style,
+ 'background-color': '#2E7D32',
+ 'box-shadow': '0 0 5px #2E7D32'
+ }
+
# Initialize the layout with improved styling
self.app.layout = html.Div([
- html.H1(f"{symbol} Real-Time Price", style={
- 'textAlign': 'center',
- 'color': '#2c3e50',
- 'fontFamily': 'Arial, sans-serif',
- 'marginTop': '20px'
- }),
+ # Header with symbol and title
html.Div([
- html.Button('1s', id='btn-1s', n_clicks=0, style={'margin': '5px'}),
- html.Button('5s', id='btn-5s', n_clicks=0, style={'margin': '5px'}),
- html.Button('15s', id='btn-15s', n_clicks=0, style={'margin': '5px'}),
- html.Button('30s', id='btn-30s', n_clicks=0, style={'margin': '5px'}),
- html.Button('1m', id='btn-1m', n_clicks=0, style={'margin': '5px'}),
- ], style={'textAlign': 'center', 'margin': '10px'}),
- dcc.Store(id='interval-store', data={'interval': 1}), # Store for current interval
+ html.H1(f"{symbol} Real-Time Price Chart", style={
+ 'textAlign': 'center',
+ 'color': '#FFFFFF',
+ 'fontFamily': 'Arial, sans-serif',
+ 'margin': '10px',
+ 'textShadow': '2px 2px 4px #000000'
+ }),
+ ], style={
+ 'backgroundColor': '#1E1E1E',
+ 'padding': '10px',
+ 'borderRadius': '5px',
+ 'marginBottom': '10px',
+ 'boxShadow': '0 4px 8px 0 rgba(0,0,0,0.2)'
+ }),
+
+ # Interval selection buttons
+ html.Div([
+ html.Div("Candlestick Interval:", style={
+ 'color': '#FFFFFF',
+ 'marginRight': '10px',
+ 'fontSize': '16px',
+ 'fontWeight': 'bold'
+ }),
+ html.Button('1s', id='btn-1s', n_clicks=0, style=active_button_style),
+ html.Button('5s', id='btn-5s', n_clicks=0, style=button_style),
+ html.Button('15s', id='btn-15s', n_clicks=0, style=button_style),
+ html.Button('30s', id='btn-30s', n_clicks=0, style=button_style),
+ html.Button('1m', id='btn-1m', n_clicks=0, style=button_style),
+ ], style={
+ 'display': 'flex',
+ 'alignItems': 'center',
+ 'justifyContent': 'center',
+ 'margin': '15px',
+ 'backgroundColor': '#2C2C2C',
+ 'padding': '10px',
+ 'borderRadius': '5px'
+ }),
+
+ # Store for current interval
+ dcc.Store(id='interval-store', data={'interval': 1}),
+
+ # Main chart
dcc.Graph(
id='live-chart',
- style={'height': '80vh'}
+ style={
+ 'height': '80vh',
+ 'border': '1px solid #444444',
+ 'borderRadius': '5px',
+ 'boxShadow': '0 4px 8px 0 rgba(0,0,0,0.2)'
+ }
),
+
+ # Update interval
dcc.Interval(
id='interval-component',
interval=500, # Update every 500ms for smoother display
n_intervals=0
)
], style={
- 'backgroundColor': '#f8f9fa',
- 'padding': '20px'
+ 'backgroundColor': '#121212',
+ 'padding': '20px',
+ 'height': '100vh',
+ 'fontFamily': 'Arial, sans-serif'
})
- # Callback to update interval based on button clicks
+ # Callback to update interval based on button clicks and update button styles
@self.app.callback(
- Output('interval-store', 'data'),
+ [Output('interval-store', 'data'),
+ Output('btn-1s', 'style'),
+ Output('btn-5s', 'style'),
+ Output('btn-15s', 'style'),
+ Output('btn-30s', 'style'),
+ Output('btn-1m', 'style')],
[Input('btn-1s', 'n_clicks'),
Input('btn-5s', 'n_clicks'),
Input('btn-15s', 'n_clicks'),
@@ -530,22 +645,45 @@ class RealTimeChart:
def update_interval(n1, n5, n15, n30, n60, data):
ctx = dash.callback_context
if not ctx.triggered:
- return data
+ # Default state (1s selected)
+ return ({'interval': 1},
+ active_button_style, button_style, button_style, button_style, button_style)
button_id = ctx.triggered[0]['prop_id'].split('.')[0]
if button_id == 'btn-1s':
- return {'interval': 1}
+ return ({'interval': 1},
+ active_button_style, button_style, button_style, button_style, button_style)
elif button_id == 'btn-5s':
- return {'interval': 5}
+ return ({'interval': 5},
+ button_style, active_button_style, button_style, button_style, button_style)
elif button_id == 'btn-15s':
- return {'interval': 15}
+ return ({'interval': 15},
+ button_style, button_style, active_button_style, button_style, button_style)
elif button_id == 'btn-30s':
- return {'interval': 30}
+ return ({'interval': 30},
+ button_style, button_style, button_style, active_button_style, button_style)
elif button_id == 'btn-1m':
- return {'interval': 60}
+ return ({'interval': 60},
+ button_style, button_style, button_style, button_style, active_button_style)
- return data
+ # Default case - keep current interval and highlight appropriate button
+ current_interval = data.get('interval', 1)
+ styles = [button_style] * 5 # All inactive by default
+
+ # Set active style based on current interval
+ if current_interval == 1:
+ styles[0] = active_button_style
+ elif current_interval == 5:
+ styles[1] = active_button_style
+ elif current_interval == 15:
+ styles[2] = active_button_style
+ elif current_interval == 30:
+ styles[3] = active_button_style
+ elif current_interval == 60:
+ styles[4] = active_button_style
+
+ return (data, *styles)
# Callback to update the chart
@self.app.callback(
@@ -556,26 +694,26 @@ class RealTimeChart:
def update_chart(n, interval_data):
try:
interval = interval_data.get('interval', 1)
- logger.info(f"Updating chart for {self.symbol} with interval {interval}s")
+ logger.debug(f"Updating chart for {self.symbol} with interval {interval}s")
- fig = make_subplots(
- rows=2, cols=1,
- shared_xaxis=True,
- vertical_spacing=0.03,
- subplot_titles=(f'{self.symbol} Price ({interval}s)', 'Volume'),
- row_heights=[0.7, 0.3]
- )
-
# Get candlesticks from tick storage
df = self.tick_storage.get_candles(interval_seconds=interval)
- # Debug information about the dataframe
- logger.info(f"Candles dataframe empty: {df.empty}, tick count: {len(self.tick_storage.ticks)}")
+ # Get current price and stats using our enhanced methods
+ current_price = self.tick_storage.get_latest_price()
+ price_stats = self.tick_storage.get_price_stats()
+ logger.debug(f"Current price: {current_price}, Stats: {price_stats}")
+
+ fig = make_subplots(
+ rows=6, cols=1, # Adjusted to accommodate new subcharts
+ vertical_spacing=0.03,
+ subplot_titles=(f'{self.symbol} Price ({interval}s)', 'Volume', '1s OHLCV', '1m OHLCV', '1h OHLCV', '1d OHLCV'),
+ row_heights=[0.4, 0.1, 0.1, 0.1, 0.1, 0.1] # Adjusted heights
+ )
+
if not df.empty and len(df) > 0:
- logger.info(f"Candles dataframe shape: {df.shape}")
- logger.info(f"Candles dataframe columns: {df.columns.tolist()}")
- logger.info(f"Candles dataframe first row: {df.iloc[0].to_dict() if len(df) > 0 else 'No rows'}")
+ logger.debug(f"Candles dataframe shape: {df.shape}, columns: {df.columns.tolist()}")
# Add candlestick chart
fig.add_trace(
@@ -606,7 +744,7 @@ class RealTimeChart:
row=2, col=1
)
- # Add latest price line and annotation
+ # Add latest price line from the candlestick data
latest_price = df['close'].iloc[-1]
fig.add_shape(
type="line",
@@ -618,6 +756,7 @@ class RealTimeChart:
row=1, col=1
)
+ # Annotation for last candle close price
fig.add_annotation(
x=df['timestamp'].max(),
y=latest_price,
@@ -627,11 +766,56 @@ class RealTimeChart:
xshift=50,
row=1, col=1
)
+
+ # If we have a more recent price from ticks, add that too
+ if current_price and abs(current_price - latest_price) > 0.01:
+ # Add current price line
+ fig.add_shape(
+ type="line",
+ x0=df['timestamp'].min(),
+ y0=current_price,
+ x1=df['timestamp'].max(),
+ y1=current_price,
+ line=dict(color="cyan", width=1, dash="dot"),
+ row=1, col=1
+ )
+
+ # Add current price annotation
+ fig.add_annotation(
+ x=df['timestamp'].max(),
+ y=current_price,
+ text=f"Current: {current_price:.2f}",
+ showarrow=False,
+ font=dict(size=14, color="cyan"),
+ xshift=50,
+ yshift=20,
+ row=1, col=1
+ )
+
+ # Fetch and cache OHLCV data for different intervals
+ for interval_key in self.ohlcv_cache.keys():
+ if self.ohlcv_cache[interval_key] is None:
+ self.ohlcv_cache[interval_key] = self.tick_storage.get_candles(interval_seconds=self._interval_to_seconds(interval_key))
+
+ # Add OHLCV subcharts
+ for i, (interval_key, ohlcv_df) in enumerate(self.ohlcv_cache.items(), start=3):
+ if ohlcv_df is not None and not ohlcv_df.empty:
+ fig.add_trace(
+ go.Candlestick(
+ x=ohlcv_df['timestamp'],
+ open=ohlcv_df['open'],
+ high=ohlcv_df['high'],
+ low=ohlcv_df['low'],
+ close=ohlcv_df['close'],
+ name=f'{interval_key} OHLCV',
+ increasing_line_color='#33CC33',
+ decreasing_line_color='#FF4136'
+ ),
+ row=i, col=1
+ )
else:
# If no data, add a text annotation to the chart
logger.warning(f"No data to display for {self.symbol} - tick count: {len(self.tick_storage.ticks)}")
- if self.tick_storage.ticks:
- logger.info(f"Sample tick: {self.tick_storage.ticks[0]}")
# Add a message to the empty chart
fig.add_annotation(
@@ -642,16 +826,70 @@ class RealTimeChart:
xref="paper", yref="paper"
)
+ # Build info box text with all the statistics
+ info_lines = [f"{self.symbol}"]
+
+ # Add current price if available
+ if current_price:
+ info_lines.append(f"Current: {current_price:.2f} USDT")
+
+ # Add price statistics if available
+ if price_stats['count'] > 0:
+ # Format time range
+ age_text = f"{price_stats['age_seconds']:.1f}s"
+ if price_stats['age_seconds'] > 60:
+ minutes = int(price_stats['age_seconds'] / 60)
+ seconds = int(price_stats['age_seconds'] % 60)
+ age_text = f"{minutes}m {seconds}s"
+
+ # Add price range and change
+ if price_stats['min'] is not None and price_stats['max'] is not None:
+ price_range = f"Range: {price_stats['min']:.2f} - {price_stats['max']:.2f}"
+ info_lines.append(price_range)
+
+ # Add tick count and time range
+ info_lines.append(f"Ticks: {price_stats['count']} in {age_text}")
+
+ # Add candle count
+ candle_count = len(df) if not df.empty else 0
+ info_lines.append(f"Candles: {candle_count} ({interval}s)")
+
+ # Add info box to the chart
+ fig.add_annotation(
+ x=0.01,
+ y=0.99,
+ xref="paper",
+ yref="paper",
+ text="
".join(info_lines),
+ showarrow=False,
+ font=dict(size=12, color="white"),
+ align="left",
+ bgcolor="rgba(0,0,50,0.7)",
+ bordercolor="#3366CC",
+ borderwidth=2,
+ borderpad=5,
+ xanchor="left",
+ yanchor="top"
+ )
+
# Update layout with improved styling
+ interval_text = {
+ 1: "1 second",
+ 5: "5 seconds",
+ 15: "15 seconds",
+ 30: "30 seconds",
+ 60: "1 minute"
+ }.get(interval, f"{interval}s")
+
fig.update_layout(
- title_text=f"{self.symbol} Real-Time Data ({interval}s candles)",
+ title_text=f"{self.symbol} Real-Time Data ({interval_text})",
title_x=0.5, # Center the title
xaxis_rangeslider_visible=False,
- height=800,
+ height=1200, # Adjusted height for new subcharts
template='plotly_dark',
paper_bgcolor='rgba(0,0,0,0)',
- plot_bgcolor='rgba(0,0,0,0)',
- font=dict(family="Arial, sans-serif", size=12, color="#2c3e50"),
+ plot_bgcolor='rgba(25,25,50,1)',
+ font=dict(family="Arial, sans-serif", size=12, color="white"),
showlegend=True,
legend=dict(
yanchor="top",
@@ -685,27 +923,60 @@ class RealTimeChart:
height=800,
template='plotly_dark',
paper_bgcolor='rgba(0,0,0,0)',
- plot_bgcolor='rgba(0,0,0,0)'
+ plot_bgcolor='rgba(25,25,50,1)'
)
return fig
+ def _interval_to_seconds(self, interval_key: str) -> int:
+ """Convert interval key to seconds"""
+ mapping = {
+ '1s': 1,
+ '1m': 60,
+ '1h': 3600,
+ '1d': 86400
+ }
+ return mapping.get(interval_key, 1)
+
async def start_websocket(self):
ws = ExchangeWebSocket(self.symbol)
+ connection_attempts = 0
+ max_attempts = 10 # Maximum connection attempts before longer waiting period
while True: # Keep trying to maintain connection
+ connection_attempts += 1
if not await ws.connect():
logger.error(f"Failed to connect to exchange for {self.symbol}")
- await asyncio.sleep(5)
+ # Gradually increase wait time based on number of connection failures
+ wait_time = min(5 * connection_attempts, 60) # Cap at 60 seconds
+ logger.warning(f"Waiting {wait_time} seconds before retry (attempt {connection_attempts})")
+
+ if connection_attempts >= max_attempts:
+ logger.warning(f"Reached {max_attempts} connection attempts, taking a longer break")
+ await asyncio.sleep(120) # 2 minutes wait after max attempts
+ connection_attempts = 0 # Reset counter
+ else:
+ await asyncio.sleep(wait_time)
continue
+ # Successfully connected
+ connection_attempts = 0
+
try:
logger.info(f"WebSocket connected for {self.symbol}, beginning data collection")
tick_count = 0
last_tick_count_log = time.time()
+ last_status_report = time.time()
+
+ # Track stats for reporting
+ price_min = float('inf')
+ price_max = float('-inf')
+ price_last = None
+ volume_total = 0
+ start_collection_time = time.time()
while True:
if not ws.running:
- logger.warning("WebSocket not running, breaking loop")
+ logger.warning(f"WebSocket connection lost for {self.symbol}, breaking loop")
break
data = await ws.receive()
@@ -729,6 +1000,14 @@ class RealTimeChart:
'volume': data['volume']
}
+ # Update stats
+ price = trade_data['price']
+ volume = trade_data['volume']
+ price_min = min(price_min, price)
+ price_max = max(price_max, price)
+ price_last = price
+ volume_total += volume
+
# Store raw tick in the tick storage
self.tick_storage.add_tick(trade_data)
tick_count += 1
@@ -739,7 +1018,9 @@ class RealTimeChart:
# Log tick counts periodically
current_time = time.time()
if current_time - last_tick_count_log >= 10: # Log every 10 seconds
- logger.info(f"{self.symbol}: Collected {tick_count} ticks in last {current_time - last_tick_count_log:.1f}s, total: {len(self.tick_storage.ticks)}")
+ elapsed = current_time - last_tick_count_log
+ tps = tick_count / elapsed if elapsed > 0 else 0
+ logger.info(f"{self.symbol}: Collected {tick_count} ticks in last {elapsed:.1f}s ({tps:.2f} ticks/sec), total: {len(self.tick_storage.ticks)}")
last_tick_count_log = current_time
tick_count = 0
@@ -747,14 +1028,34 @@ class RealTimeChart:
if len(self.tick_storage.ticks) > 0:
sample_df = self.tick_storage.get_candles(interval_seconds=1)
logger.info(f"{self.symbol}: Sample candle count: {len(sample_df)}")
+
+ # Periodic status report (every 60 seconds)
+ if current_time - last_status_report >= 60:
+ elapsed_total = current_time - start_collection_time
+ logger.info(f"{self.symbol} Status Report:")
+ logger.info(f" Collection time: {elapsed_total:.1f} seconds")
+ logger.info(f" Price range: {price_min:.2f} - {price_max:.2f} (last: {price_last:.2f})")
+ logger.info(f" Total volume: {volume_total:.8f}")
+ logger.info(f" Active ticks in storage: {len(self.tick_storage.ticks)}")
+
+ # Reset stats for next period
+ last_status_report = current_time
+ price_min = float('inf') if price_last is None else price_last
+ price_max = float('-inf') if price_last is None else price_last
+ volume_total = 0
await asyncio.sleep(0.01)
+ except websockets.exceptions.ConnectionClosed as e:
+ logger.error(f"WebSocket connection closed for {self.symbol}: {str(e)}")
except Exception as e:
- logger.error(f"Error in WebSocket loop: {str(e)}")
+ logger.error(f"Error in WebSocket loop for {self.symbol}: {str(e)}")
+ import traceback
+ logger.error(traceback.format_exc())
finally:
+ logger.info(f"Closing WebSocket connection for {self.symbol}")
await ws.close()
- logger.info("Waiting 5 seconds before reconnecting...")
+ logger.info(f"Waiting 5 seconds before reconnecting {self.symbol} WebSocket...")
await asyncio.sleep(5)
def run(self, host='localhost', port=8050):