multiple pages - candle chart + raw ticks

This commit is contained in:
Dobromir Popov 2025-03-18 23:59:28 +02:00
parent c4a803876e
commit 158f9ebf71

View File

@ -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("<b>Time-Based Stats:</b>")
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 = {