2025-03-25 08:15:27 +08:00
|
|
|
import mplfinance as mpf
|
|
|
|
|
import pandas as pd
|
|
|
|
|
import matplotlib.pyplot as plt
|
|
|
|
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
|
|
|
|
|
import matplotlib.animation as animation
|
|
|
|
|
import ccxt
|
|
|
|
|
from matplotlib.dates import date2num
|
|
|
|
|
import threading
|
|
|
|
|
import tkinter as tk
|
|
|
|
|
from tkinter import ttk
|
2025-03-25 17:00:53 +08:00
|
|
|
import queue
|
|
|
|
|
from datetime import datetime
|
2025-03-25 08:15:27 +08:00
|
|
|
|
|
|
|
|
class ChartView:
|
2025-03-25 17:00:53 +08:00
|
|
|
def __init__(self, exchange_ids, symbol='BTC/USDT', timeframe='1m', limit=100, alert_queue=None):
|
2025-03-25 08:15:27 +08:00
|
|
|
self.exchange_ids = exchange_ids
|
|
|
|
|
self.symbol = symbol
|
|
|
|
|
self.timeframe = timeframe
|
|
|
|
|
self.limit = limit
|
|
|
|
|
self.running = False
|
|
|
|
|
self.fig = None
|
|
|
|
|
self.ax1 = None
|
|
|
|
|
self.ax2 = None
|
2025-03-25 17:00:53 +08:00
|
|
|
self.ax_whales = None # New axis for whale activity
|
2025-03-25 08:15:27 +08:00
|
|
|
self.animation = None
|
|
|
|
|
self.current_exchange_id = exchange_ids[0]
|
|
|
|
|
self.root = None
|
|
|
|
|
self.canvas = None
|
2025-03-25 17:00:53 +08:00
|
|
|
self.alert_queue = alert_queue
|
|
|
|
|
self.whale_markers = []
|
|
|
|
|
|
|
|
|
|
# Maintain a dict of whale alerts by exchange
|
|
|
|
|
self.whale_alerts = {exchange_id: [] for exchange_id in exchange_ids}
|
2025-03-25 08:15:27 +08:00
|
|
|
|
|
|
|
|
# Create exchanges dictionary to avoid reconnecting each time
|
|
|
|
|
self.exchanges = {}
|
|
|
|
|
for exchange_id in exchange_ids:
|
|
|
|
|
self.connect_to_exchange(exchange_id)
|
|
|
|
|
|
|
|
|
|
def connect_to_exchange(self, exchange_id):
|
|
|
|
|
if exchange_id in self.exchanges:
|
|
|
|
|
self.exchange = self.exchanges[exchange_id]
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
exchange_class = getattr(ccxt, exchange_id)
|
|
|
|
|
exchange = exchange_class({
|
|
|
|
|
'enableRateLimit': True,
|
|
|
|
|
})
|
|
|
|
|
exchange.load_markets()
|
|
|
|
|
|
|
|
|
|
if self.symbol not in exchange.markets:
|
|
|
|
|
raise Exception(f"Symbol {self.symbol} not found on {exchange_id}")
|
|
|
|
|
|
|
|
|
|
self.exchanges[exchange_id] = exchange
|
|
|
|
|
self.exchange = exchange
|
|
|
|
|
except Exception as e:
|
|
|
|
|
raise Exception(f"Failed to connect to {exchange_id}: {str(e)}")
|
|
|
|
|
|
|
|
|
|
def fetch_ohlcv(self, exchange_id):
|
|
|
|
|
try:
|
|
|
|
|
exchange = self.exchanges[exchange_id]
|
|
|
|
|
ohlcv = exchange.fetch_ohlcv(self.symbol, self.timeframe, limit=self.limit)
|
|
|
|
|
df = pd.DataFrame(ohlcv, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
|
|
|
|
|
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
|
|
|
|
|
df.set_index('timestamp', inplace=True)
|
|
|
|
|
return df
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error fetching OHLCV data from {exchange_id}: {str(e)}")
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def update_chart(self):
|
|
|
|
|
if not self.running:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
df = self.fetch_ohlcv(self.current_exchange_id)
|
|
|
|
|
if df is not None:
|
|
|
|
|
self.ax1.clear()
|
|
|
|
|
self.ax2.clear()
|
2025-03-25 17:00:53 +08:00
|
|
|
self.ax_whales.clear()
|
|
|
|
|
|
|
|
|
|
# Process any pending whale alerts from the queue
|
|
|
|
|
self.process_whale_queue()
|
2025-03-25 08:15:27 +08:00
|
|
|
|
2025-03-25 17:00:53 +08:00
|
|
|
# Plot the chart
|
2025-03-25 08:15:27 +08:00
|
|
|
mpf.plot(df, type='candle', ax=self.ax1, volume=self.ax2,
|
|
|
|
|
style='yahoo', xrotation=0, ylabel='Price',
|
|
|
|
|
ylabel_lower='Volume', show_nontrading=False)
|
|
|
|
|
|
|
|
|
|
latest_price = df['close'].iloc[-1]
|
|
|
|
|
self.ax1.set_title(f'{self.symbol} on {self.current_exchange_id} - Last: ${latest_price:.2f}')
|
|
|
|
|
|
2025-03-25 17:00:53 +08:00
|
|
|
# Plot whale activity on the dedicated subplot
|
|
|
|
|
self.plot_whale_activity(df)
|
|
|
|
|
|
2025-03-25 08:15:27 +08:00
|
|
|
# Refresh the canvas
|
|
|
|
|
self.canvas.draw()
|
|
|
|
|
|
2025-03-25 17:00:53 +08:00
|
|
|
def process_whale_queue(self):
|
|
|
|
|
"""Process any new whale alerts from the queue"""
|
|
|
|
|
if self.alert_queue is None:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Process all available alerts
|
|
|
|
|
while not self.alert_queue.empty():
|
|
|
|
|
try:
|
|
|
|
|
alert = self.alert_queue.get_nowait()
|
|
|
|
|
exchange_id = alert['exchange']
|
|
|
|
|
|
|
|
|
|
# Add timestamp if not present
|
|
|
|
|
if 'timestamp' not in alert:
|
|
|
|
|
alert['timestamp'] = datetime.now().isoformat()
|
|
|
|
|
|
|
|
|
|
# Store the alert with the appropriate exchange
|
|
|
|
|
if exchange_id in self.whale_alerts:
|
|
|
|
|
self.whale_alerts[exchange_id].append(alert)
|
|
|
|
|
print(f"New whale alert for {exchange_id}: {alert['type']} ${alert['value_usd']:,.2f}")
|
|
|
|
|
|
|
|
|
|
self.alert_queue.task_done()
|
|
|
|
|
except queue.Empty:
|
|
|
|
|
break
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error processing whale alert from queue: {str(e)}")
|
|
|
|
|
if self.alert_queue is not None:
|
|
|
|
|
self.alert_queue.task_done()
|
|
|
|
|
|
|
|
|
|
def plot_whale_activity(self, df):
|
|
|
|
|
"""Plot whale activity on the dedicated whale subplot"""
|
|
|
|
|
if self.current_exchange_id not in self.whale_alerts:
|
|
|
|
|
self.ax_whales.set_ylabel('Whale Activity')
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Get the whale alerts for current exchange
|
|
|
|
|
exchange_alerts = self.whale_alerts[self.current_exchange_id]
|
|
|
|
|
|
|
|
|
|
if not exchange_alerts:
|
|
|
|
|
self.ax_whales.set_ylabel('Whale Activity')
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Create a dataframe from the whale alerts
|
|
|
|
|
alerts_df = pd.DataFrame(exchange_alerts)
|
|
|
|
|
|
|
|
|
|
# Handle the timestamp conversion carefully
|
|
|
|
|
alerts_df['timestamp'] = pd.to_datetime(alerts_df['timestamp'])
|
|
|
|
|
|
|
|
|
|
# Check if the dataframe timestamps have timezone info
|
|
|
|
|
has_tz = False
|
|
|
|
|
if not alerts_df.empty:
|
|
|
|
|
has_tz = alerts_df['timestamp'].iloc[0].tzinfo is not None
|
|
|
|
|
|
|
|
|
|
# Get the start and end time of the current chart
|
|
|
|
|
start_time = df.index[0]
|
|
|
|
|
end_time = df.index[-1]
|
|
|
|
|
|
|
|
|
|
# Convert all timestamps to naive (remove timezone info) for comparison
|
|
|
|
|
# First create a copy of the timestamp column
|
|
|
|
|
alerts_df['plot_timestamp'] = alerts_df['timestamp']
|
|
|
|
|
|
|
|
|
|
# If timestamps have timezone, convert them to naive by replacing with their UTC equivalent
|
|
|
|
|
if has_tz:
|
|
|
|
|
alerts_df['plot_timestamp'] = alerts_df['timestamp'].dt.tz_localize(None)
|
|
|
|
|
else:
|
|
|
|
|
# If timestamps are naive, assume they're in local time and adjust by GMT+8 offset
|
|
|
|
|
alerts_df['plot_timestamp'] = alerts_df['timestamp'] - pd.Timedelta(hours=8)
|
|
|
|
|
|
|
|
|
|
# Filter to only include alerts in the visible time range
|
|
|
|
|
visible_alerts = alerts_df[
|
|
|
|
|
(alerts_df['plot_timestamp'] >= start_time) &
|
|
|
|
|
(alerts_df['plot_timestamp'] <= end_time)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if visible_alerts.empty:
|
|
|
|
|
self.ax_whales.set_ylabel('Whale Activity')
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
# Create two separate series for buy and sell orders
|
|
|
|
|
buy_orders = visible_alerts[visible_alerts['type'] == 'bid'].copy()
|
|
|
|
|
sell_orders = visible_alerts[visible_alerts['type'] == 'ask'].copy()
|
|
|
|
|
|
|
|
|
|
# Draw the buy orders as green bars going up
|
|
|
|
|
if not buy_orders.empty:
|
|
|
|
|
buy_values = buy_orders['value_usd'] / 1_000_000 # Convert to millions
|
|
|
|
|
self.ax_whales.bar(buy_orders['plot_timestamp'], buy_values,
|
|
|
|
|
color='green', alpha=0.6, width=pd.Timedelta(minutes=1))
|
|
|
|
|
|
|
|
|
|
# Draw the sell orders as red bars going down
|
|
|
|
|
if not sell_orders.empty:
|
|
|
|
|
sell_values = -1 * sell_orders['value_usd'] / 1_000_000 # Convert to millions and make negative
|
|
|
|
|
self.ax_whales.bar(sell_orders['plot_timestamp'], sell_values,
|
|
|
|
|
color='red', alpha=0.6, width=pd.Timedelta(minutes=1))
|
|
|
|
|
|
|
|
|
|
# Format the whale activity subplot
|
|
|
|
|
self.ax_whales.set_ylabel('Whale Activity ($M)')
|
|
|
|
|
|
|
|
|
|
# Add zero line
|
|
|
|
|
self.ax_whales.axhline(y=0, color='black', linestyle='-', alpha=0.3)
|
|
|
|
|
|
|
|
|
|
# Set y-axis limits with some padding
|
|
|
|
|
all_values = visible_alerts['value_usd'] / 1_000_000
|
|
|
|
|
if len(all_values) > 0:
|
|
|
|
|
max_val = all_values.max() * 1.1
|
|
|
|
|
self.ax_whales.set_ylim(-max_val, max_val)
|
|
|
|
|
|
|
|
|
|
# Align the x-axis with the price chart
|
|
|
|
|
self.ax_whales.sharex(self.ax1)
|
|
|
|
|
|
|
|
|
|
# Add text labels for significant whale activity
|
|
|
|
|
for idx, row in visible_alerts.iterrows():
|
|
|
|
|
value_millions = row['value_usd'] / 1_000_000
|
|
|
|
|
sign = 1 if row['type'] == 'bid' else -1
|
|
|
|
|
position = sign * value_millions
|
|
|
|
|
if abs(value_millions) > max(all_values) * 0.3: # Only label significant activity
|
|
|
|
|
self.ax_whales.text(
|
|
|
|
|
row['plot_timestamp'], position * 1.05,
|
|
|
|
|
f"${abs(value_millions):.1f}M",
|
|
|
|
|
ha='center', va='bottom' if sign > 0 else 'top',
|
|
|
|
|
fontsize=8, color='green' if sign > 0 else 'red'
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"Error plotting whale activity: {str(e)}")
|
|
|
|
|
import traceback
|
|
|
|
|
traceback.print_exc()
|
|
|
|
|
self.ax_whales.set_ylabel('Whale Activity - Error plotting data')
|
|
|
|
|
|
|
|
|
|
def parse_timeframe_to_minutes(self, timeframe):
|
|
|
|
|
"""Convert timeframe string to minutes"""
|
|
|
|
|
if timeframe.endswith('m'):
|
|
|
|
|
return int(timeframe[:-1])
|
|
|
|
|
elif timeframe.endswith('h'):
|
|
|
|
|
return int(timeframe[:-1]) * 60
|
|
|
|
|
elif timeframe.endswith('d'):
|
|
|
|
|
return int(timeframe[:-1]) * 1440
|
|
|
|
|
return 1 # default to 1 minute
|
|
|
|
|
|
2025-03-25 08:15:27 +08:00
|
|
|
def on_exchange_change(self, event=None):
|
|
|
|
|
selected_exchange = self.exchange_var.get()
|
|
|
|
|
if selected_exchange != self.current_exchange_id:
|
|
|
|
|
self.current_exchange_id = selected_exchange
|
|
|
|
|
self.update_chart()
|
|
|
|
|
|
|
|
|
|
def start_gui(self):
|
|
|
|
|
self.root = tk.Tk()
|
|
|
|
|
self.root.title("Crypto Chart Viewer")
|
|
|
|
|
self.root.geometry("1200x800")
|
|
|
|
|
|
|
|
|
|
# Create frame for controls
|
|
|
|
|
control_frame = ttk.Frame(self.root)
|
|
|
|
|
control_frame.pack(side=tk.TOP, fill=tk.X, padx=10, pady=5)
|
|
|
|
|
|
|
|
|
|
# Dropdown for selecting exchange
|
|
|
|
|
ttk.Label(control_frame, text="Exchange:").pack(side=tk.LEFT, padx=(0, 5))
|
|
|
|
|
self.exchange_var = tk.StringVar(value=self.current_exchange_id)
|
|
|
|
|
exchange_dropdown = ttk.Combobox(control_frame, textvariable=self.exchange_var, values=self.exchange_ids, width=15)
|
|
|
|
|
exchange_dropdown.pack(side=tk.LEFT, padx=(0, 10))
|
|
|
|
|
exchange_dropdown.bind("<<ComboboxSelected>>", self.on_exchange_change)
|
|
|
|
|
|
2025-03-25 17:00:53 +08:00
|
|
|
# Create the figure with three subplots
|
2025-03-25 08:15:27 +08:00
|
|
|
self.fig = plt.Figure(figsize=(12, 8), dpi=100)
|
2025-03-25 17:00:53 +08:00
|
|
|
|
|
|
|
|
# Adjust the subplot grid to add whale activity panel
|
|
|
|
|
# Price chart (60%), Volume (20%), Whale Activity (20%)
|
|
|
|
|
self.ax1 = self.fig.add_subplot(5, 1, (1, 3)) # Price chart - 3/5 of the space
|
|
|
|
|
self.ax2 = self.fig.add_subplot(5, 1, 4, sharex=self.ax1) # Volume - 1/5 of the space
|
|
|
|
|
self.ax_whales = self.fig.add_subplot(5, 1, 5, sharex=self.ax1) # Whale activity - 1/5 of the space
|
2025-03-25 08:15:27 +08:00
|
|
|
|
|
|
|
|
# Create the canvas to display the figure
|
|
|
|
|
self.canvas = FigureCanvasTkAgg(self.fig, master=self.root)
|
|
|
|
|
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
|
|
|
|
|
|
|
|
|
# Add toolbar
|
|
|
|
|
toolbar = NavigationToolbar2Tk(self.canvas, self.root)
|
|
|
|
|
toolbar.update()
|
|
|
|
|
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
|
|
|
|
|
|
|
|
|
|
# Initial chart update
|
|
|
|
|
self.running = True
|
|
|
|
|
self.update_chart()
|
|
|
|
|
|
|
|
|
|
# Set up periodic updates
|
|
|
|
|
def update():
|
|
|
|
|
if self.running:
|
|
|
|
|
self.update_chart()
|
|
|
|
|
self.root.after(10000, update) # Update every 10 seconds
|
|
|
|
|
|
|
|
|
|
self.root.after(10000, update)
|
|
|
|
|
|
|
|
|
|
# Handle window close
|
|
|
|
|
def on_closing():
|
|
|
|
|
self.running = False
|
|
|
|
|
self.root.destroy()
|
|
|
|
|
|
|
|
|
|
self.root.protocol("WM_DELETE_WINDOW", on_closing)
|
|
|
|
|
|
|
|
|
|
# Start the Tkinter event loop
|
|
|
|
|
self.root.mainloop()
|