diff --git a/realtime.py b/realtime.py index bdc87d0..c6744f1 100644 --- a/realtime.py +++ b/realtime.py @@ -31,13 +31,14 @@ logger = logging.getLogger(__name__) class TradeTickStorage: """Store and manage raw trade ticks for display and candle formation""" - def __init__(self, max_age_seconds: int = 300): # 5 minutes by default + def __init__(self, max_age_seconds: int = 1800): # 30 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 + # Adjust cleanup interval based on max_age_seconds (more time = less frequent cleanup) + self.cleanup_interval = min(max(10, max_age_seconds // 60), 60) # Between 10-60 seconds self.last_tick = None - logger.info(f"Initialized TradeTickStorage with max age: {max_age_seconds} seconds") + logger.info(f"Initialized TradeTickStorage with max age: {max_age_seconds} seconds, cleanup interval: {self.cleanup_interval} seconds") def add_tick(self, tick: Dict): """Add a new trade tick to storage""" @@ -128,8 +129,17 @@ class TradeTickStorage: return pd.DataFrame() return df - def get_candles(self, interval_seconds: int = 1) -> pd.DataFrame: - """Convert ticks to OHLCV candles at specified interval""" + def get_candles(self, interval_seconds: int = 1, start_time_ms: int = None, end_time_ms: int = None) -> pd.DataFrame: + """Convert ticks to OHLCV candles at specified interval with optional time range filtering + + Args: + interval_seconds: The interval in seconds for each candle + start_time_ms: Optional start time in milliseconds for filtering + end_time_ms: Optional end time in milliseconds for filtering + + Returns: + DataFrame with OHLCV candles + """ if not self.ticks: logger.warning("No ticks available for candle formation") return pd.DataFrame() @@ -137,10 +147,20 @@ class TradeTickStorage: # Ensure ticks are up to date self._cleanup() - # Convert to DataFrame - df = self.get_ticks_as_df() + # Get ticks from specified time range if provided + if start_time_ms is not None or end_time_ms is not None: + logger.debug(f"Filtering ticks for time range from {start_time_ms} to {end_time_ms}") + filtered_ticks = self.get_ticks_from_time(start_time_ms, end_time_ms) + if not filtered_ticks: + logger.warning("No ticks in the specified time range") + return pd.DataFrame() + df = pd.DataFrame(filtered_ticks) + else: + # Use all available ticks + df = self.get_ticks_as_df() + if df.empty: - logger.warning("Tick DataFrame is empty after conversion") + logger.warning("Tick DataFrame is empty after filtering/conversion") return pd.DataFrame() logger.info(f"Preparing to create candles from {len(df)} ticks") @@ -178,7 +198,7 @@ class TradeTickStorage: # Ensure no NaN values candles = candles.dropna() - logger.debug(f"Generated {len(candles)} candles from {len(self.ticks)} ticks") + logger.debug(f"Generated {len(candles)} candles from {len(df)} ticks") return candles except Exception as e: @@ -187,6 +207,88 @@ class TradeTickStorage: logger.error(traceback.format_exc()) return pd.DataFrame() + def get_ticks_from_time(self, start_time_ms: int = None, end_time_ms: int = None) -> List[Dict]: + """Get ticks within a specific time range + + Args: + start_time_ms: Start time in milliseconds (None for no lower bound) + end_time_ms: End time in milliseconds (None for no upper bound) + + Returns: + List of ticks within the time range + """ + if not self.ticks: + return [] + + # Ensure ticks are updated + self._cleanup() + + # Apply time filters if specified + filtered_ticks = self.ticks + if start_time_ms is not None: + filtered_ticks = [tick for tick in filtered_ticks if tick['timestamp'] >= start_time_ms] + if end_time_ms is not None: + filtered_ticks = [tick for tick in filtered_ticks if tick['timestamp'] <= end_time_ms] + + logger.debug(f"Retrieved {len(filtered_ticks)} ticks from time range {start_time_ms} to {end_time_ms}") + return filtered_ticks + + def get_time_based_stats(self) -> Dict: + """Get statistics about the ticks organized by time periods + + Returns: + Dictionary with statistics for different time periods + """ + if not self.ticks: + return { + 'total_ticks': 0, + 'periods': {} + } + + # Ensure ticks are updated + self._cleanup() + + now = int(time.time() * 1000) # Current time in ms + + # Define time periods to analyze + periods = { + '1min': now - (60 * 1000), + '5min': now - (5 * 60 * 1000), + '15min': now - (15 * 60 * 1000), + '30min': now - (30 * 60 * 1000) + } + + stats = { + 'total_ticks': len(self.ticks), + 'oldest_tick': self.ticks[0]['timestamp'] if self.ticks else None, + 'newest_tick': self.ticks[-1]['timestamp'] if self.ticks else None, + 'time_span_seconds': (self.ticks[-1]['timestamp'] - self.ticks[0]['timestamp']) / 1000 if self.ticks else 0, + 'periods': {} + } + + # Calculate stats for each period + for period_name, cutoff_time in periods.items(): + period_ticks = [tick for tick in self.ticks if tick['timestamp'] >= cutoff_time] + + if period_ticks: + prices = [tick['price'] for tick in period_ticks] + volumes = [tick.get('volume', 0) for tick in period_ticks] + + period_stats = { + 'tick_count': len(period_ticks), + 'min_price': min(prices) if prices else None, + 'max_price': max(prices) if prices else None, + 'avg_price': sum(prices) / len(prices) if prices else None, + 'last_price': period_ticks[-1]['price'] if period_ticks else None, + 'total_volume': sum(volumes), + 'ticks_per_second': len(period_ticks) / (int(period_name[:-3]) * 60) if period_ticks else 0 + } + + stats['periods'][period_name] = period_stats + + logger.debug(f"Generated time-based stats: {len(stats['periods'])} periods") + return stats + class CandlestickData: def __init__(self, max_length: int = 300): self.timestamps = deque(maxlen=max_length) @@ -592,9 +694,12 @@ class CandleCache: class RealTimeChart: def __init__(self, symbol: str): self.symbol = symbol - self.app = dash.Dash(__name__) + # Create a multi-page Dash app instead of a simple Dash app + self.app = dash.Dash(__name__, + suppress_callback_exceptions=True, + meta_tags=[{"name": "viewport", "content": "width=device-width, initial-scale=1"}]) self.candlestick_data = CandlestickData() - self.tick_storage = TradeTickStorage(max_age_seconds=300) # Store 5 minutes of ticks + self.tick_storage = TradeTickStorage(max_age_seconds=1800) # Store 30 minutes of ticks self.ohlcv_cache = { # Cache for different intervals '1s': None, '1m': None, @@ -608,6 +713,10 @@ class RealTimeChart: # Load historical data for longer timeframes at startup self._load_historical_data() + # Setup the multi-page layout + self._setup_app_layout() + + def _setup_app_layout(self): # Button style button_style = { 'background-color': '#4CAF50', @@ -628,17 +737,50 @@ class RealTimeChart: 'box-shadow': '0 0 5px #2E7D32' } - # Initialize the layout with improved styling + nav_button_style = { + 'background-color': '#3f51b5', + 'color': 'white', + 'padding': '10px 15px', + 'margin': '5px', + 'border': 'none', + 'border-radius': '5px', + 'font-size': '14px', + 'cursor': 'pointer', + 'font-weight': 'bold' + } + + # Content div for the current page + content_div = html.Div(id='page-content') + + # Initialize the layout with navigation self.app.layout = html.Div([ # Header with symbol and title html.Div([ - html.H1(f"{symbol} Real-Time Price Chart", style={ + html.H1(f"{self.symbol} Real-Time Data", style={ 'textAlign': 'center', 'color': '#FFFFFF', 'fontFamily': 'Arial, sans-serif', 'margin': '10px', 'textShadow': '2px 2px 4px #000000' }), + + # Navigation bar + html.Div([ + dcc.Link( + html.Button('Price Chart', style=nav_button_style), + href=f'/{self.symbol.replace("/", "-")}/chart', + id='chart-link' + ), + dcc.Link( + html.Button('Raw Ticks', style=nav_button_style), + href=f'/{self.symbol.replace("/", "-")}/ticks', + id='ticks-link' + ), + ], style={ + 'display': 'flex', + 'justifyContent': 'center', + 'margin': '10px' + }), ], style={ 'backgroundColor': '#1E1E1E', 'padding': '10px', @@ -647,6 +789,57 @@ class RealTimeChart: 'boxShadow': '0 4px 8px 0 rgba(0,0,0,0.2)' }), + # URL Bar + dcc.Location(id='url', refresh=False), + + # Content div will be populated based on the URL + content_div, + + # Interval component for periodic updates + dcc.Interval( + id='interval-component', + interval=500, # Update every 500ms for smoother display + n_intervals=0 + ) + ], style={ + 'backgroundColor': '#121212', + 'padding': '20px', + 'minHeight': '100vh', + 'fontFamily': 'Arial, sans-serif' + }) + + # Register URL callback to update page content + @self.app.callback( + Output('page-content', 'children'), + [Input('url', 'pathname')] + ) + def display_page(pathname): + if pathname is None: + pathname = f'/{self.symbol.replace("/", "-")}/chart' + + symbol_path = self.symbol.replace("/", "-") + + if pathname == f'/{symbol_path}/chart' or pathname == f'/' or pathname == f'/{symbol_path}': + return self._get_chart_layout(button_style, active_button_style) + elif pathname == f'/{symbol_path}/ticks': + return self._get_ticks_layout() + else: + return html.Div([ + html.H1('404 - Page Not Found', style={'textAlign': 'center', 'color': 'white'}) + ]) + + # Register callback to update the interval selection from button clicks + self._setup_interval_callback(button_style, active_button_style) + + # Register callback to update the chart data + self._setup_chart_callback() + + # Register callback to update the ticks data + self._setup_ticks_callback() + + def _get_chart_layout(self, button_style, active_button_style): + # Chart page layout + return html.Div([ # Interval selection buttons html.Div([ html.Div("Candlestick Interval:", style={ @@ -677,26 +870,98 @@ class RealTimeChart: dcc.Graph( id='live-chart', style={ - 'height': '80vh', + 'height': '180vh', 'border': '1px solid #444444', 'borderRadius': '5px', 'boxShadow': '0 4px 8px 0 rgba(0,0,0,0.2)' } ), + ]) + + def _get_ticks_layout(self): + # Ticks data page layout + return html.Div([ + # Header and controls + html.Div([ + html.H2(f"{self.symbol} Raw Tick Data (Last 5 Minutes)", style={ + 'textAlign': 'center', + 'color': '#FFFFFF', + 'margin': '10px 0' + }), + + # Refresh button + html.Button('Refresh Data', id='refresh-ticks-btn', n_clicks=0, style={ + 'backgroundColor': '#4CAF50', + 'color': 'white', + 'padding': '10px 20px', + 'margin': '10px auto', + 'border': 'none', + 'borderRadius': '5px', + 'fontSize': '14px', + 'cursor': 'pointer', + 'display': 'block' + }), + + # Time window selector + html.Div([ + html.Label("Time Window:", style={'color': 'white', 'marginRight': '10px'}), + dcc.Dropdown( + id='time-window-dropdown', + options=[ + {'label': 'Last 1 minute', 'value': 60}, + {'label': 'Last 5 minutes', 'value': 300}, + {'label': 'Last 15 minutes', 'value': 900}, + {'label': 'Last 30 minutes', 'value': 1800}, + ], + value=300, # Default to 5 minutes + style={'width': '200px', 'backgroundColor': '#2C2C2C', 'color': 'black'} + ) + ], style={ + 'display': 'flex', + 'alignItems': 'center', + 'justifyContent': 'center', + 'margin': '10px' + }), + ], style={ + 'backgroundColor': '#2C2C2C', + 'padding': '10px', + 'borderRadius': '5px', + 'marginBottom': '15px' + }), - # Update interval - dcc.Interval( - id='interval-component', - interval=500, # Update every 500ms for smoother display - n_intervals=0 - ) - ], style={ - 'backgroundColor': '#121212', - 'padding': '20px', - 'height': '100vh', - 'fontFamily': 'Arial, sans-serif' - }) - + # Stats cards + html.Div(id='tick-stats-cards', style={ + 'display': 'flex', + 'flexWrap': 'wrap', + 'justifyContent': 'space-around', + 'marginBottom': '15px' + }), + + # Ticks data table + html.Div(id='ticks-table-container', style={ + 'backgroundColor': '#232323', + 'padding': '10px', + 'borderRadius': '5px', + 'overflowX': 'auto' + }), + + # Price movement chart + html.Div([ + html.H3("Price Movement", style={ + 'textAlign': 'center', + 'color': '#FFFFFF', + 'margin': '10px 0' + }), + dcc.Graph(id='tick-price-chart') + ], style={ + 'backgroundColor': '#232323', + 'padding': '10px', + 'borderRadius': '5px', + 'marginTop': '15px' + }) + ]) + + def _setup_interval_callback(self, button_style, active_button_style): # Callback to update interval based on button clicks and update button styles @self.app.callback( [Output('interval-store', 'data'), @@ -754,7 +1019,8 @@ class RealTimeChart: styles[4] = active_button_style return (data, *styles) - + + def _setup_chart_callback(self): # Callback to update the chart @self.app.callback( Output('live-chart', 'figure'), @@ -772,14 +1038,15 @@ class RealTimeChart: # 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() + time_stats = self.tick_storage.get_time_based_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, + vertical_spacing=0.02, # Reduced for better use of vertical space 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 + row_heights=[0.5, 0.1, 0.1, 0.1, 0.1, 0.1] # Give more space to main chart ) if not df.empty and len(df) > 0: @@ -931,6 +1198,17 @@ class RealTimeChart: # Add candle count candle_count = len(df) if not df.empty else 0 info_lines.append(f"Candles: {candle_count} ({interval}s)") + + # Add time-based statistics + if time_stats and time_stats['periods']: + info_lines.append("Time-Based Stats:") + for period, stats in time_stats['periods'].items(): + if stats['tick_count'] > 0: + info_lines.append(f"{period}: {stats['tick_count']} ticks, {stats['ticks_per_second']:.2f}/s") + if stats['min_price'] is not None and stats['max_price'] is not None: + price_change = stats['last_price'] - stats['min_price'] + change_pct = (price_change / stats['min_price']) * 100 if stats['min_price'] > 0 else 0 + info_lines.append(f" Range: {stats['min_price']:.2f}-{stats['max_price']:.2f} ({change_pct:.2f}%)") # Add info box to the chart fig.add_annotation( @@ -963,7 +1241,7 @@ class RealTimeChart: title_text=f"{self.symbol} Real-Time Data ({interval_text})", title_x=0.5, # Center the title xaxis_rangeslider_visible=False, - height=1200, # Adjusted height for new subcharts + height=1800, # Increased height for taller display template='plotly_dark', paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(25,25,50,1)', @@ -1005,6 +1283,313 @@ class RealTimeChart: ) return fig + def _setup_ticks_callback(self): + # Callbacks to update the ticks data display + + # Callback to update stats cards + @self.app.callback( + Output('tick-stats-cards', 'children'), + [Input('interval-component', 'n_intervals'), + Input('refresh-ticks-btn', 'n_clicks'), + Input('time-window-dropdown', 'value')] + ) + def update_tick_stats(n_intervals, n_clicks, time_window): + # Get time window in seconds + window_seconds = time_window if time_window else 300 + + # Calculate time range for filtering + now = int(time.time() * 1000) + start_time = now - (window_seconds * 1000) + + # Get filtered ticks + filtered_ticks = self.tick_storage.get_ticks_from_time(start_time_ms=start_time) + + if not filtered_ticks: + return [html.Div("No tick data available in the selected time window.", + style={'color': 'white', 'textAlign': 'center', 'margin': '20px'})] + + # Calculate stats + tick_count = len(filtered_ticks) + + prices = [tick['price'] for tick in filtered_ticks] + min_price = min(prices) if prices else 0 + max_price = max(prices) if prices else 0 + avg_price = sum(prices) / len(prices) if prices else 0 + price_range = max_price - min_price + + volumes = [tick.get('volume', 0) for tick in filtered_ticks] + total_volume = sum(volumes) + avg_volume = total_volume / len(volumes) if volumes else 0 + + first_timestamp = filtered_ticks[0]['timestamp'] + last_timestamp = filtered_ticks[-1]['timestamp'] + time_span_ms = last_timestamp - first_timestamp + time_span_seconds = time_span_ms / 1000 + ticks_per_second = tick_count / time_span_seconds if time_span_seconds > 0 else 0 + + # Create stat cards + card_style = { + 'backgroundColor': '#1E2130', + 'borderRadius': '5px', + 'padding': '15px', + 'marginBottom': '10px', + 'width': '23%', + 'boxShadow': '0 2px 4px rgba(0,0,0,0.2)', + 'color': 'white', + 'display': 'flex', + 'flexDirection': 'column', + 'alignItems': 'center', + 'justifyContent': 'center' + } + + mobile_card_style = { + **card_style, + 'width': '100%', + 'marginBottom': '10px' + } + + value_style = { + 'fontSize': '24px', + 'fontWeight': 'bold', + 'color': '#4CAF50', + 'marginTop': '5px' + } + + label_style = { + 'fontSize': '14px', + 'color': '#AAAAAA' + } + + return [ + html.Div([ + html.Div("Tick Count", style=label_style), + html.Div(f"{tick_count:,}", style=value_style), + html.Div(f"{ticks_per_second:.2f} ticks/sec", style=label_style) + ], style=card_style), + + html.Div([ + html.Div("Price Range", style=label_style), + html.Div(f"{min_price:.2f} - {max_price:.2f}", style=value_style), + html.Div(f"Range: {price_range:.2f}", style=label_style) + ], style=card_style), + + html.Div([ + html.Div("Average Price", style=label_style), + html.Div(f"{avg_price:.2f}", style=value_style), + html.Div("USDT", style=label_style) + ], style=card_style), + + html.Div([ + html.Div("Total Volume", style=label_style), + html.Div(f"{total_volume:.8f}", style=value_style), + html.Div(f"Avg: {avg_volume:.8f}", style=label_style) + ], style=card_style) + ] + + # Callback to update ticks table + @self.app.callback( + Output('ticks-table-container', 'children'), + [Input('interval-component', 'n_intervals'), + Input('refresh-ticks-btn', 'n_clicks'), + Input('time-window-dropdown', 'value')] + ) + def update_ticks_table(n_intervals, n_clicks, time_window): + # Get time window in seconds + window_seconds = time_window if time_window else 300 + + # Calculate time range for filtering + now = int(time.time() * 1000) + start_time = now - (window_seconds * 1000) + + # Get filtered ticks + filtered_ticks = self.tick_storage.get_ticks_from_time(start_time_ms=start_time) + + if not filtered_ticks: + return html.Div("No tick data available in the selected time window.", + style={'color': 'white', 'textAlign': 'center', 'margin': '20px'}) + + # Convert to readable format + formatted_ticks = [] + for tick in filtered_ticks: + timestamp_dt = datetime.fromtimestamp(tick['timestamp'] / 1000) + formatted_time = timestamp_dt.strftime('%H:%M:%S.%f')[:-3] # Format as HH:MM:SS.mmm + + formatted_ticks.append({ + 'time': formatted_time, + 'price': f"{tick['price']:.2f}", + 'volume': f"{tick['volume']:.8f}", + 'value': f"{tick['price'] * tick['volume']:.2f}" + }) + + # Display only the most recent ticks + display_limit = 100 + limited_ticks = formatted_ticks[-display_limit:] if len(formatted_ticks) > display_limit else formatted_ticks + limited_ticks.reverse() # Show most recent at the top + + # Create table + table_style = { + 'width': '100%', + 'borderCollapse': 'collapse', + 'color': 'white', + 'fontFamily': 'monospace' + } + + header_style = { + 'backgroundColor': '#1A1A1A', + 'fontWeight': 'bold', + 'padding': '8px', + 'textAlign': 'left', + 'borderBottom': '2px solid #444' + } + + cell_style = { + 'padding': '6px', + 'borderBottom': '1px solid #333', + 'textAlign': 'right' + } + + time_cell_style = { + **cell_style, + 'textAlign': 'left' + } + + # Create table header + header = html.Tr([ + html.Th("Time", style=header_style), + html.Th("Price", style=header_style), + html.Th("Volume", style=header_style), + html.Th("Value (USDT)", style=header_style) + ]) + + # Create table rows + rows = [] + for i, tick in enumerate(limited_ticks): + row_style = {'backgroundColor': '#1E1E1E'} if i % 2 == 0 else {'backgroundColor': '#252525'} + + rows.append(html.Tr([ + html.Td(tick['time'], style={**time_cell_style, **row_style}), + html.Td(tick['price'], style={**cell_style, **row_style}), + html.Td(tick['volume'], style={**cell_style, **row_style}), + html.Td(tick['value'], style={**cell_style, **row_style}) + ])) + + return [ + html.Div([ + html.H3(f"Latest {len(limited_ticks)} Ticks (from {len(filtered_ticks)} total)", + style={'color': 'white', 'marginBottom': '10px', 'textAlign': 'center'}), + html.Table([html.Thead(header), html.Tbody(rows)], style=table_style) + ]) + ] + + # Callback to update price chart + @self.app.callback( + Output('tick-price-chart', 'figure'), + [Input('interval-component', 'n_intervals'), + Input('refresh-ticks-btn', 'n_clicks'), + Input('time-window-dropdown', 'value')] + ) + def update_price_chart(n_intervals, n_clicks, time_window): + # Get time window in seconds + window_seconds = time_window if time_window else 300 + + # Calculate time range for filtering + now = int(time.time() * 1000) + start_time = now - (window_seconds * 1000) + + # Get filtered ticks + filtered_ticks = self.tick_storage.get_ticks_from_time(start_time_ms=start_time) + + # Create figure + fig = go.Figure() + + if not filtered_ticks: + fig.add_annotation( + x=0.5, y=0.5, + text="No tick data available in the selected time window.", + showarrow=False, + font=dict(size=14, color="white"), + xref="paper", yref="paper" + ) + else: + # Convert timestamps to datetime for better x-axis display + timestamps = [datetime.fromtimestamp(tick['timestamp'] / 1000) for tick in filtered_ticks] + prices = [tick['price'] for tick in filtered_ticks] + volumes = [tick.get('volume', 0) for tick in filtered_ticks] + + # Scale volumes for better visibility + max_volume = max(volumes) if volumes else 1 + scaled_volumes = [vol * (max(prices) - min(prices)) / max_volume * 0.2 + min(prices) for vol in volumes] + + # Add price line + fig.add_trace(go.Scatter( + x=timestamps, + y=prices, + mode='lines', + name='Price', + line=dict(color='#4CAF50', width=1.5) + )) + + # Add volume bars + fig.add_trace(go.Bar( + x=timestamps, + y=scaled_volumes, + name='Volume', + marker=dict(color='rgba(128, 128, 255, 0.3)'), + opacity=0.5, + yaxis='y2' + )) + + # Add annotations for latest price + latest_price = prices[-1] if prices else 0 + fig.add_annotation( + x=timestamps[-1] if timestamps else 0, + y=latest_price, + text=f"{latest_price:.2f}", + showarrow=True, + arrowhead=1, + arrowsize=1, + arrowwidth=2, + arrowcolor="#4CAF50", + font=dict(size=12, color="#4CAF50"), + xshift=50 + ) + + # Update layout + fig.update_layout( + title=f"{self.symbol} Price Movement (Last {window_seconds // 60} minutes)", + title_x=0.5, + xaxis=dict( + title="Time", + showgrid=True, + gridcolor='rgba(128,128,128,0.2)' + ), + yaxis=dict( + title="Price (USDT)", + showgrid=True, + gridcolor='rgba(128,128,128,0.2)' + ), + yaxis2=dict( + title="Volume", + overlaying='y', + side='right', + showgrid=False, + showticklabels=False + ), + template='plotly_dark', + paper_bgcolor='rgba(0,0,0,0)', + plot_bgcolor='rgba(25,25,50,1)', + height=500, + margin=dict(l=40, r=40, t=50, b=40), + legend=dict( + yanchor="top", + y=0.99, + xanchor="left", + x=0.01 + ) + ) + + return fig + def _interval_to_seconds(self, interval_key: str) -> int: """Convert interval key to seconds""" mapping = {