multiple pages - candle chart + raw ticks
This commit is contained in:
parent
c4a803876e
commit
158f9ebf71
647
realtime.py
647
realtime.py
@ -31,13 +31,14 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class TradeTickStorage:
|
class TradeTickStorage:
|
||||||
"""Store and manage raw trade ticks for display and candle formation"""
|
"""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.ticks = []
|
||||||
self.max_age_seconds = max_age_seconds
|
self.max_age_seconds = max_age_seconds
|
||||||
self.last_cleanup_time = time.time()
|
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
|
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):
|
def add_tick(self, tick: Dict):
|
||||||
"""Add a new trade tick to storage"""
|
"""Add a new trade tick to storage"""
|
||||||
@ -128,8 +129,17 @@ class TradeTickStorage:
|
|||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
return df
|
return df
|
||||||
|
|
||||||
def get_candles(self, interval_seconds: int = 1) -> pd.DataFrame:
|
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"""
|
"""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:
|
if not self.ticks:
|
||||||
logger.warning("No ticks available for candle formation")
|
logger.warning("No ticks available for candle formation")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
@ -137,10 +147,20 @@ class TradeTickStorage:
|
|||||||
# Ensure ticks are up to date
|
# Ensure ticks are up to date
|
||||||
self._cleanup()
|
self._cleanup()
|
||||||
|
|
||||||
# Convert to DataFrame
|
# Get ticks from specified time range if provided
|
||||||
df = self.get_ticks_as_df()
|
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:
|
if df.empty:
|
||||||
logger.warning("Tick DataFrame is empty after conversion")
|
logger.warning("Tick DataFrame is empty after filtering/conversion")
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
logger.info(f"Preparing to create candles from {len(df)} ticks")
|
logger.info(f"Preparing to create candles from {len(df)} ticks")
|
||||||
@ -178,7 +198,7 @@ class TradeTickStorage:
|
|||||||
# Ensure no NaN values
|
# Ensure no NaN values
|
||||||
candles = candles.dropna()
|
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
|
return candles
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -187,6 +207,88 @@ class TradeTickStorage:
|
|||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
return pd.DataFrame()
|
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:
|
class CandlestickData:
|
||||||
def __init__(self, max_length: int = 300):
|
def __init__(self, max_length: int = 300):
|
||||||
self.timestamps = deque(maxlen=max_length)
|
self.timestamps = deque(maxlen=max_length)
|
||||||
@ -592,9 +694,12 @@ class CandleCache:
|
|||||||
class RealTimeChart:
|
class RealTimeChart:
|
||||||
def __init__(self, symbol: str):
|
def __init__(self, symbol: str):
|
||||||
self.symbol = symbol
|
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.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
|
self.ohlcv_cache = { # Cache for different intervals
|
||||||
'1s': None,
|
'1s': None,
|
||||||
'1m': None,
|
'1m': None,
|
||||||
@ -608,6 +713,10 @@ class RealTimeChart:
|
|||||||
# Load historical data for longer timeframes at startup
|
# Load historical data for longer timeframes at startup
|
||||||
self._load_historical_data()
|
self._load_historical_data()
|
||||||
|
|
||||||
|
# Setup the multi-page layout
|
||||||
|
self._setup_app_layout()
|
||||||
|
|
||||||
|
def _setup_app_layout(self):
|
||||||
# Button style
|
# Button style
|
||||||
button_style = {
|
button_style = {
|
||||||
'background-color': '#4CAF50',
|
'background-color': '#4CAF50',
|
||||||
@ -628,17 +737,50 @@ class RealTimeChart:
|
|||||||
'box-shadow': '0 0 5px #2E7D32'
|
'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([
|
self.app.layout = html.Div([
|
||||||
# Header with symbol and title
|
# Header with symbol and title
|
||||||
html.Div([
|
html.Div([
|
||||||
html.H1(f"{symbol} Real-Time Price Chart", style={
|
html.H1(f"{self.symbol} Real-Time Data", style={
|
||||||
'textAlign': 'center',
|
'textAlign': 'center',
|
||||||
'color': '#FFFFFF',
|
'color': '#FFFFFF',
|
||||||
'fontFamily': 'Arial, sans-serif',
|
'fontFamily': 'Arial, sans-serif',
|
||||||
'margin': '10px',
|
'margin': '10px',
|
||||||
'textShadow': '2px 2px 4px #000000'
|
'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={
|
], style={
|
||||||
'backgroundColor': '#1E1E1E',
|
'backgroundColor': '#1E1E1E',
|
||||||
'padding': '10px',
|
'padding': '10px',
|
||||||
@ -647,6 +789,57 @@ class RealTimeChart:
|
|||||||
'boxShadow': '0 4px 8px 0 rgba(0,0,0,0.2)'
|
'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
|
# Interval selection buttons
|
||||||
html.Div([
|
html.Div([
|
||||||
html.Div("Candlestick Interval:", style={
|
html.Div("Candlestick Interval:", style={
|
||||||
@ -677,26 +870,98 @@ class RealTimeChart:
|
|||||||
dcc.Graph(
|
dcc.Graph(
|
||||||
id='live-chart',
|
id='live-chart',
|
||||||
style={
|
style={
|
||||||
'height': '80vh',
|
'height': '180vh',
|
||||||
'border': '1px solid #444444',
|
'border': '1px solid #444444',
|
||||||
'borderRadius': '5px',
|
'borderRadius': '5px',
|
||||||
'boxShadow': '0 4px 8px 0 rgba(0,0,0,0.2)'
|
'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
|
# Stats cards
|
||||||
dcc.Interval(
|
html.Div(id='tick-stats-cards', style={
|
||||||
id='interval-component',
|
'display': 'flex',
|
||||||
interval=500, # Update every 500ms for smoother display
|
'flexWrap': 'wrap',
|
||||||
n_intervals=0
|
'justifyContent': 'space-around',
|
||||||
)
|
'marginBottom': '15px'
|
||||||
], style={
|
}),
|
||||||
'backgroundColor': '#121212',
|
|
||||||
'padding': '20px',
|
# Ticks data table
|
||||||
'height': '100vh',
|
html.Div(id='ticks-table-container', style={
|
||||||
'fontFamily': 'Arial, sans-serif'
|
'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
|
# Callback to update interval based on button clicks and update button styles
|
||||||
@self.app.callback(
|
@self.app.callback(
|
||||||
[Output('interval-store', 'data'),
|
[Output('interval-store', 'data'),
|
||||||
@ -754,7 +1019,8 @@ class RealTimeChart:
|
|||||||
styles[4] = active_button_style
|
styles[4] = active_button_style
|
||||||
|
|
||||||
return (data, *styles)
|
return (data, *styles)
|
||||||
|
|
||||||
|
def _setup_chart_callback(self):
|
||||||
# Callback to update the chart
|
# Callback to update the chart
|
||||||
@self.app.callback(
|
@self.app.callback(
|
||||||
Output('live-chart', 'figure'),
|
Output('live-chart', 'figure'),
|
||||||
@ -772,14 +1038,15 @@ class RealTimeChart:
|
|||||||
# Get current price and stats using our enhanced methods
|
# Get current price and stats using our enhanced methods
|
||||||
current_price = self.tick_storage.get_latest_price()
|
current_price = self.tick_storage.get_latest_price()
|
||||||
price_stats = self.tick_storage.get_price_stats()
|
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}")
|
logger.debug(f"Current price: {current_price}, Stats: {price_stats}")
|
||||||
|
|
||||||
fig = make_subplots(
|
fig = make_subplots(
|
||||||
rows=6, cols=1, # Adjusted to accommodate new subcharts
|
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'),
|
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:
|
if not df.empty and len(df) > 0:
|
||||||
@ -931,6 +1198,17 @@ class RealTimeChart:
|
|||||||
# Add candle count
|
# Add candle count
|
||||||
candle_count = len(df) if not df.empty else 0
|
candle_count = len(df) if not df.empty else 0
|
||||||
info_lines.append(f"Candles: {candle_count} ({interval}s)")
|
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
|
# Add info box to the chart
|
||||||
fig.add_annotation(
|
fig.add_annotation(
|
||||||
@ -963,7 +1241,7 @@ class RealTimeChart:
|
|||||||
title_text=f"{self.symbol} Real-Time Data ({interval_text})",
|
title_text=f"{self.symbol} Real-Time Data ({interval_text})",
|
||||||
title_x=0.5, # Center the title
|
title_x=0.5, # Center the title
|
||||||
xaxis_rangeslider_visible=False,
|
xaxis_rangeslider_visible=False,
|
||||||
height=1200, # Adjusted height for new subcharts
|
height=1800, # Increased height for taller display
|
||||||
template='plotly_dark',
|
template='plotly_dark',
|
||||||
paper_bgcolor='rgba(0,0,0,0)',
|
paper_bgcolor='rgba(0,0,0,0)',
|
||||||
plot_bgcolor='rgba(25,25,50,1)',
|
plot_bgcolor='rgba(25,25,50,1)',
|
||||||
@ -1005,6 +1283,313 @@ class RealTimeChart:
|
|||||||
)
|
)
|
||||||
return fig
|
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:
|
def _interval_to_seconds(self, interval_key: str) -> int:
|
||||||
"""Convert interval key to seconds"""
|
"""Convert interval key to seconds"""
|
||||||
mapping = {
|
mapping = {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user