From 7ea193d70ffc0bd96df142d45a9bb80cc1f9ac00 Mon Sep 17 00:00:00 2001 From: Dobromir Popov Date: Tue, 18 Mar 2025 23:28:11 +0200 Subject: [PATCH] fixed miltiframe chart --- realtime.py | 413 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 357 insertions(+), 56 deletions(-) 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):