fixed miltiframe chart

This commit is contained in:
Dobromir Popov 2025-03-18 23:28:11 +02:00
parent 4aefca2d6c
commit 7ea193d70f

View File

@ -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
# 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")
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]
)
logger.debug(f"Updating chart for {self.symbol} with interval {interval}s")
# 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"<b>{self.symbol}</b>"]
# Add current price if available
if current_price:
info_lines.append(f"Current: <b>{current_price:.2f}</b> 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="<br>".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
@ -748,13 +1029,33 @@ class RealTimeChart:
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):