1487 lines
60 KiB
Python
1487 lines
60 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
|
|
from typing import Dict, List, Optional
|
|
import websockets
|
|
import plotly.graph_objects as go
|
|
from plotly.subplots import make_subplots
|
|
import dash
|
|
from dash import html, dcc
|
|
from dash.dependencies import Input, Output
|
|
import pandas as pd
|
|
import numpy as np
|
|
from collections import deque
|
|
import time
|
|
from threading import Thread
|
|
import requests
|
|
import os
|
|
from datetime import datetime, timedelta
|
|
import pytz
|
|
import tzlocal
|
|
import threading
|
|
import random
|
|
import dash_bootstrap_components as dbc
|
|
import uuid
|
|
|
|
class BinanceHistoricalData:
|
|
"""
|
|
Class for fetching historical price data from Binance.
|
|
"""
|
|
def __init__(self):
|
|
self.base_url = "https://api.binance.com/api/v3"
|
|
|
|
def get_historical_candles(self, symbol, interval_seconds=3600, limit=1000):
|
|
"""
|
|
Fetch historical candles from Binance API.
|
|
|
|
Args:
|
|
symbol (str): Trading pair symbol (e.g., "BTC/USDT")
|
|
interval_seconds (int): Timeframe in seconds (e.g., 3600 for 1h)
|
|
limit (int): Number of candles to fetch
|
|
|
|
Returns:
|
|
pd.DataFrame: DataFrame with OHLCV data
|
|
"""
|
|
# Convert interval_seconds to Binance interval format
|
|
interval_map = {
|
|
1: "1s",
|
|
60: "1m",
|
|
300: "5m",
|
|
900: "15m",
|
|
1800: "30m",
|
|
3600: "1h",
|
|
14400: "4h",
|
|
86400: "1d"
|
|
}
|
|
|
|
interval = interval_map.get(interval_seconds, "1h")
|
|
|
|
# Format symbol for Binance API (remove slash)
|
|
formatted_symbol = symbol.replace("/", "")
|
|
|
|
try:
|
|
# Build URL for klines endpoint
|
|
url = f"{self.base_url}/klines"
|
|
params = {
|
|
"symbol": formatted_symbol,
|
|
"interval": interval,
|
|
"limit": limit
|
|
}
|
|
|
|
# Make the request
|
|
response = requests.get(url, params=params)
|
|
response.raise_for_status()
|
|
|
|
# Parse the response
|
|
data = response.json()
|
|
|
|
# Create dataframe
|
|
df = pd.DataFrame(data, columns=[
|
|
"timestamp", "open", "high", "low", "close", "volume",
|
|
"close_time", "quote_asset_volume", "number_of_trades",
|
|
"taker_buy_base_asset_volume", "taker_buy_quote_asset_volume", "ignore"
|
|
])
|
|
|
|
# Convert timestamp to datetime
|
|
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
|
|
|
|
# Convert price columns to float
|
|
for col in ["open", "high", "low", "close", "volume"]:
|
|
df[col] = df[col].astype(float)
|
|
|
|
# Sort by timestamp
|
|
df = df.sort_values("timestamp")
|
|
|
|
logger.info(f"Fetched {len(df)} candles for {symbol} ({interval})")
|
|
return df
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching historical data from Binance: {str(e)}")
|
|
# Return empty dataframe on error
|
|
return pd.DataFrame()
|
|
|
|
def get_recent_trades(self, symbol, limit=1000):
|
|
"""Get recent trades for a symbol"""
|
|
formatted_symbol = symbol.replace("/", "")
|
|
|
|
try:
|
|
url = f"{self.base_url}/trades"
|
|
params = {
|
|
"symbol": formatted_symbol,
|
|
"limit": limit
|
|
}
|
|
|
|
response = requests.get(url, params=params)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
# Create dataframe
|
|
df = pd.DataFrame(data)
|
|
df["time"] = pd.to_datetime(df["time"], unit="ms")
|
|
df["price"] = df["price"].astype(float)
|
|
df["qty"] = df["qty"].astype(float)
|
|
|
|
return df
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching recent trades: {str(e)}")
|
|
return pd.DataFrame()
|
|
|
|
# Configure logging with more detailed format
|
|
logging.basicConfig(
|
|
level=logging.INFO, # Changed to DEBUG for more detailed logs
|
|
format='%(asctime)s - %(levelname)s - [%(filename)s:%(lineno)d] - %(message)s',
|
|
handlers=[
|
|
logging.StreamHandler(),
|
|
logging.FileHandler('realtime_chart.log')
|
|
]
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Neural Network integration (conditional import)
|
|
NN_ENABLED = os.environ.get('ENABLE_NN_MODELS', '0') == '1'
|
|
nn_orchestrator = None
|
|
nn_inference_thread = None
|
|
|
|
if NN_ENABLED:
|
|
try:
|
|
import sys
|
|
# Add project root to sys.path if needed
|
|
project_root = os.path.dirname(os.path.abspath(__file__))
|
|
if project_root not in sys.path:
|
|
sys.path.append(project_root)
|
|
|
|
from NN.main import NeuralNetworkOrchestrator
|
|
logger.info("Neural Network module enabled")
|
|
except ImportError as e:
|
|
logger.warning(f"Failed to import Neural Network module, disabling NN features: {str(e)}")
|
|
NN_ENABLED = False
|
|
|
|
# NN utility functions
|
|
def setup_neural_network():
|
|
"""Initialize the neural network components if enabled"""
|
|
global nn_orchestrator, NN_ENABLED
|
|
|
|
if not NN_ENABLED:
|
|
return False
|
|
|
|
try:
|
|
# Get configuration from environment variables or use defaults
|
|
symbol = os.environ.get('NN_SYMBOL', 'ETH/USDT')
|
|
timeframes = os.environ.get('NN_TIMEFRAMES', '1m,5m,1h,4h,1d').split(',')
|
|
output_size = int(os.environ.get('NN_OUTPUT_SIZE', '3')) # 3 for BUY/HOLD/SELL
|
|
|
|
# Configure the orchestrator
|
|
config = {
|
|
'symbol': symbol,
|
|
'timeframes': timeframes,
|
|
'window_size': int(os.environ.get('NN_WINDOW_SIZE', '20')),
|
|
'n_features': 5, # OHLCV
|
|
'output_size': output_size,
|
|
'model_dir': 'NN/models/saved',
|
|
'data_dir': 'NN/data'
|
|
}
|
|
|
|
# Initialize the orchestrator
|
|
logger.info(f"Initializing Neural Network Orchestrator with config: {config}")
|
|
nn_orchestrator = NeuralNetworkOrchestrator(config)
|
|
|
|
# Start inference thread if enabled
|
|
inference_interval = int(os.environ.get('NN_INFERENCE_INTERVAL', '60'))
|
|
if inference_interval > 0:
|
|
start_nn_inference_thread(inference_interval)
|
|
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error setting up neural network: {str(e)}")
|
|
import traceback
|
|
logger.error(traceback.format_exc())
|
|
NN_ENABLED = False
|
|
return False
|
|
|
|
def start_nn_inference_thread(interval_seconds):
|
|
"""Start a background thread to periodically run inference with the neural network"""
|
|
global nn_inference_thread
|
|
|
|
if not NN_ENABLED or nn_orchestrator is None:
|
|
logger.warning("Cannot start inference thread - Neural Network not enabled or initialized")
|
|
return False
|
|
|
|
def inference_worker():
|
|
"""Worker function for the inference thread"""
|
|
model_type = os.environ.get('NN_MODEL_TYPE', 'cnn')
|
|
timeframe = os.environ.get('NN_TIMEFRAME', '1h')
|
|
|
|
logger.info(f"Starting neural network inference thread with {interval_seconds}s interval")
|
|
logger.info(f"Using model type: {model_type}, timeframe: {timeframe}")
|
|
|
|
# Wait a bit for charts to initialize
|
|
time.sleep(5)
|
|
|
|
# Track active charts
|
|
active_charts = []
|
|
|
|
while True:
|
|
try:
|
|
# Find active charts if we don't have them yet
|
|
if not active_charts and 'charts' in globals():
|
|
active_charts = globals()['charts']
|
|
logger.info(f"Found {len(active_charts)} active charts for NN signals")
|
|
|
|
# Run inference
|
|
result = nn_orchestrator.run_inference_pipeline(
|
|
model_type=model_type,
|
|
timeframe=timeframe
|
|
)
|
|
|
|
if result:
|
|
# Log the result
|
|
logger.info(f"Neural network inference result: {result}")
|
|
|
|
# Add signal to charts
|
|
if active_charts:
|
|
try:
|
|
if 'action' in result:
|
|
action = result['action']
|
|
timestamp = datetime.fromisoformat(result['timestamp'].replace('Z', '+00:00'))
|
|
|
|
# Get probability if available
|
|
probability = None
|
|
if 'probability' in result:
|
|
probability = result['probability']
|
|
elif 'probabilities' in result:
|
|
probability = result['probabilities'].get(action, None)
|
|
|
|
# Add signal to each chart
|
|
for chart in active_charts:
|
|
if hasattr(chart, 'add_nn_signal'):
|
|
chart.add_nn_signal(action, timestamp, probability)
|
|
except Exception as e:
|
|
logger.error(f"Error adding NN signal to chart: {str(e)}")
|
|
import traceback
|
|
logger.error(traceback.format_exc())
|
|
|
|
# Sleep for the interval
|
|
time.sleep(interval_seconds)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in inference thread: {str(e)}")
|
|
import traceback
|
|
logger.error(traceback.format_exc())
|
|
time.sleep(5) # Wait a bit before retrying
|
|
|
|
# Create and start the thread
|
|
nn_inference_thread = threading.Thread(target=inference_worker, daemon=True)
|
|
nn_inference_thread.start()
|
|
|
|
return True
|
|
|
|
# Try to get local timezone, default to Sofia/EET if not available
|
|
try:
|
|
local_timezone = tzlocal.get_localzone()
|
|
# Get timezone name safely
|
|
try:
|
|
tz_name = str(local_timezone)
|
|
# Handle case where it might be zoneinfo.ZoneInfo object instead of pytz timezone
|
|
if hasattr(local_timezone, 'zone'):
|
|
tz_name = local_timezone.zone
|
|
elif hasattr(local_timezone, 'key'):
|
|
tz_name = local_timezone.key
|
|
else:
|
|
tz_name = str(local_timezone)
|
|
except:
|
|
tz_name = "Local"
|
|
logger.info(f"Detected local timezone: {local_timezone} ({tz_name})")
|
|
except Exception as e:
|
|
logger.warning(f"Could not detect local timezone: {str(e)}. Defaulting to Sofia/EET")
|
|
local_timezone = pytz.timezone('Europe/Sofia')
|
|
tz_name = "Europe/Sofia"
|
|
|
|
def convert_to_local_time(timestamp):
|
|
"""Convert timestamp to local timezone"""
|
|
try:
|
|
if isinstance(timestamp, pd.Timestamp):
|
|
dt = timestamp.to_pydatetime()
|
|
elif isinstance(timestamp, np.datetime64):
|
|
dt = pd.Timestamp(timestamp).to_pydatetime()
|
|
elif isinstance(timestamp, str):
|
|
dt = pd.to_datetime(timestamp).to_pydatetime()
|
|
else:
|
|
dt = timestamp
|
|
|
|
# If datetime is naive (no timezone), assume it's UTC
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=pytz.UTC)
|
|
|
|
# Convert to local timezone
|
|
local_dt = dt.astimezone(local_timezone)
|
|
return local_dt
|
|
except Exception as e:
|
|
logger.error(f"Error converting timestamp to local time: {str(e)}")
|
|
return timestamp
|
|
|
|
class TickStorage:
|
|
"""Simple storage for ticks and candles"""
|
|
def __init__(self):
|
|
self.ticks = []
|
|
self.candles = {}
|
|
self.latest_price = None
|
|
|
|
def add_tick(self, price, volume=0, timestamp=None):
|
|
"""Add a tick to the storage"""
|
|
if timestamp is None:
|
|
timestamp = datetime.now()
|
|
|
|
tick = {
|
|
'price': price,
|
|
'volume': volume,
|
|
'timestamp': timestamp
|
|
}
|
|
|
|
self.ticks.append(tick)
|
|
self.latest_price = price
|
|
|
|
# Keep only last 10000 ticks
|
|
if len(self.ticks) > 10000:
|
|
self.ticks = self.ticks[-10000:]
|
|
|
|
# Update candles
|
|
self._update_candles(tick)
|
|
|
|
def get_latest_price(self):
|
|
"""Get the latest price"""
|
|
return self.latest_price
|
|
|
|
def _update_candles(self, tick):
|
|
"""Update candles with the new tick"""
|
|
intervals = {
|
|
'1m': 60,
|
|
'5m': 300,
|
|
'15m': 900,
|
|
'1h': 3600,
|
|
'4h': 14400,
|
|
'1d': 86400
|
|
}
|
|
|
|
for interval_key, seconds in intervals.items():
|
|
if interval_key not in self.candles:
|
|
self.candles[interval_key] = []
|
|
|
|
# Get or create the current candle
|
|
current_candle = self._get_current_candle(interval_key, tick['timestamp'], seconds)
|
|
|
|
# Update the candle with the new tick
|
|
if current_candle['high'] < tick['price']:
|
|
current_candle['high'] = tick['price']
|
|
if current_candle['low'] > tick['price']:
|
|
current_candle['low'] = tick['price']
|
|
current_candle['close'] = tick['price']
|
|
current_candle['volume'] += tick['volume']
|
|
|
|
def _get_current_candle(self, interval_key, timestamp, interval_seconds):
|
|
"""Get the current candle for the given interval, or create a new one"""
|
|
# Calculate the candle start time
|
|
candle_start = timestamp.replace(
|
|
microsecond=0,
|
|
second=0,
|
|
minute=(timestamp.minute // (interval_seconds // 60)) * (interval_seconds // 60)
|
|
)
|
|
|
|
if interval_seconds >= 3600: # For hourly or higher
|
|
hours = (timestamp.hour // (interval_seconds // 3600)) * (interval_seconds // 3600)
|
|
candle_start = candle_start.replace(hour=hours)
|
|
|
|
if interval_seconds >= 86400: # For daily
|
|
candle_start = candle_start.replace(hour=0)
|
|
|
|
# Check if we already have a candle for this time
|
|
for candle in self.candles[interval_key]:
|
|
if candle['timestamp'] == candle_start:
|
|
return candle
|
|
|
|
# Create a new candle
|
|
candle = {
|
|
'timestamp': candle_start,
|
|
'open': self.latest_price if self.latest_price is not None else tick['price'],
|
|
'high': tick['price'],
|
|
'low': tick['price'],
|
|
'close': tick['price'],
|
|
'volume': 0
|
|
}
|
|
|
|
self.candles[interval_key].append(candle)
|
|
return candle
|
|
|
|
def get_candles(self, interval='1m'):
|
|
"""Get candles for the given interval"""
|
|
if interval not in self.candles or not self.candles[interval]:
|
|
return None
|
|
|
|
# Convert to DataFrame
|
|
df = pd.DataFrame(self.candles[interval])
|
|
df.set_index('timestamp', inplace=True)
|
|
return df
|
|
|
|
def load_from_file(self, file_path):
|
|
"""Load ticks from a file"""
|
|
try:
|
|
df = pd.read_csv(file_path)
|
|
for _, row in df.iterrows():
|
|
if 'timestamp' in row:
|
|
timestamp = pd.to_datetime(row['timestamp'])
|
|
else:
|
|
timestamp = None
|
|
|
|
self.add_tick(
|
|
price=row.get('price', row.get('close', 0)),
|
|
volume=row.get('volume', 0),
|
|
timestamp=timestamp
|
|
)
|
|
logger.info(f"Loaded {len(df)} ticks from {file_path}")
|
|
except Exception as e:
|
|
logger.error(f"Error loading ticks from file: {str(e)}")
|
|
|
|
def load_historical_data(self, historical_data, symbol):
|
|
"""Load historical data"""
|
|
try:
|
|
df = historical_data.get_historical_candles(symbol)
|
|
if df is not None and not df.empty:
|
|
for _, row in df.iterrows():
|
|
self.add_tick(
|
|
price=row['close'],
|
|
volume=row['volume'],
|
|
timestamp=row['timestamp']
|
|
)
|
|
logger.info(f"Loaded {len(df)} historical candles for {symbol}")
|
|
except Exception as e:
|
|
logger.error(f"Error loading historical data: {str(e)}")
|
|
import traceback
|
|
logger.error(traceback.format_exc())
|
|
|
|
class Position:
|
|
"""Class representing a trading position"""
|
|
def __init__(self, action, entry_price, amount, timestamp=None, trade_id=None):
|
|
self.action = action # BUY or SELL
|
|
self.entry_price = entry_price
|
|
self.amount = amount
|
|
self.timestamp = timestamp or datetime.now()
|
|
self.status = "OPEN" # OPEN or CLOSED
|
|
self.exit_price = None
|
|
self.exit_timestamp = None
|
|
self.pnl = 0.0
|
|
self.trade_id = trade_id or str(uuid.uuid4())
|
|
|
|
def close(self, exit_price, exit_timestamp=None):
|
|
"""Close the position with an exit price"""
|
|
self.exit_price = exit_price
|
|
self.exit_timestamp = exit_timestamp or datetime.now()
|
|
self.status = "CLOSED"
|
|
|
|
# Calculate PnL
|
|
if self.action == "BUY":
|
|
self.pnl = (self.exit_price - self.entry_price) * self.amount
|
|
else: # SELL
|
|
self.pnl = (self.entry_price - self.exit_price) * self.amount
|
|
|
|
return self.pnl
|
|
|
|
class RealTimeChart:
|
|
"""Real-time chart using Dash and Plotly"""
|
|
|
|
def __init__(self, symbol, data_path=None, historical_data=None, exchange=None, timeframe='1m'):
|
|
"""Initialize the RealTimeChart class"""
|
|
self.symbol = symbol
|
|
self.exchange = exchange
|
|
self.app = dash.Dash(__name__, external_stylesheets=[dbc.themes.DARKLY])
|
|
self.tick_storage = TickStorage()
|
|
self.historical_data = historical_data
|
|
self.data_path = data_path
|
|
self.current_interval = '1m' # Default interval
|
|
self.fig = None # Will hold the main chart figure
|
|
self.positions = [] # List to hold position objects
|
|
self.balance = 1000.0 # Starting balance
|
|
self.last_action = None # Last trading action
|
|
|
|
self._setup_app_layout()
|
|
|
|
# Run the app in a separate thread
|
|
threading.Thread(target=self._run_app, daemon=True).start()
|
|
|
|
def _setup_app_layout(self):
|
|
"""Set up the app layout and callbacks"""
|
|
# Define styling for interval buttons
|
|
button_style = {
|
|
'backgroundColor': '#2C2C2C',
|
|
'color': 'white',
|
|
'border': 'none',
|
|
'padding': '10px 15px',
|
|
'margin': '5px',
|
|
'borderRadius': '5px',
|
|
'cursor': 'pointer',
|
|
'fontWeight': 'bold'
|
|
}
|
|
|
|
active_button_style = {
|
|
**button_style,
|
|
'backgroundColor': '#4CAF50',
|
|
'boxShadow': '0 2px 4px rgba(0,0,0,0.5)'
|
|
}
|
|
|
|
# Create tab layout
|
|
self.app.layout = dbc.Tabs([
|
|
dbc.Tab(self._get_chart_layout(button_style, active_button_style), label="Chart", tab_id="chart-tab"),
|
|
# No longer need ticks tab as it's causing errors
|
|
], id="tabs")
|
|
|
|
# Set up callbacks
|
|
self._setup_interval_callback(button_style, active_button_style)
|
|
self._setup_chart_callback()
|
|
self._setup_position_list_callback()
|
|
self._setup_trading_status_callback()
|
|
# We've removed the ticks callback, so don't call it
|
|
# self._setup_ticks_callback()
|
|
|
|
def _get_chart_layout(self, button_style, active_button_style):
|
|
"""Get the chart layout"""
|
|
return html.Div([
|
|
# Trading stats header at the top
|
|
html.Div([
|
|
html.Div([
|
|
html.Div([
|
|
html.Span("Signal: ", style={'fontWeight': 'bold', 'marginRight': '5px'}),
|
|
html.Span("NONE", id='current-signal-value', style={'color': 'white'})
|
|
], style={'marginRight': '20px', 'display': 'inline-block'}),
|
|
html.Div([
|
|
html.Span("Position: ", style={'fontWeight': 'bold', 'marginRight': '5px'}),
|
|
html.Span("NONE", id='current-position-value', style={'color': 'white'})
|
|
], style={'marginRight': '20px', 'display': 'inline-block'}),
|
|
html.Div([
|
|
html.Span("Balance: ", style={'fontWeight': 'bold', 'marginRight': '5px'}),
|
|
html.Span("$0.00", id='current-balance-value', style={'color': 'white'})
|
|
], style={'marginRight': '20px', 'display': 'inline-block'}),
|
|
html.Div([
|
|
html.Span("Session PnL: ", style={'fontWeight': 'bold', 'marginRight': '5px'}),
|
|
html.Span("$0.00", id='current-pnl-value', style={'color': 'white'})
|
|
], style={'display': 'inline-block'})
|
|
], style={
|
|
'padding': '10px',
|
|
'backgroundColor': '#222222',
|
|
'borderRadius': '5px',
|
|
'marginBottom': '10px',
|
|
'border': '1px solid #444444'
|
|
})
|
|
]),
|
|
|
|
# Recent Trades Table (compact at top)
|
|
html.Div([
|
|
html.H4("Recent Trades", style={'color': 'white', 'margin': '5px 0', 'fontSize': '14px'}),
|
|
html.Table([
|
|
html.Thead(html.Tr([
|
|
html.Th("Status", style={'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px', 'fontWeight': 'bold', 'backgroundColor': '#333333'}),
|
|
html.Th("Amount", style={'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px', 'fontWeight': 'bold', 'backgroundColor': '#333333'}),
|
|
html.Th("Entry Price", style={'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px', 'fontWeight': 'bold', 'backgroundColor': '#333333'}),
|
|
html.Th("Exit Price", style={'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px', 'fontWeight': 'bold', 'backgroundColor': '#333333'}),
|
|
html.Th("PnL", style={'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px', 'fontWeight': 'bold', 'backgroundColor': '#333333'}),
|
|
html.Th("Time", style={'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px', 'fontWeight': 'bold', 'backgroundColor': '#333333'})
|
|
])),
|
|
html.Tbody(id='position-list', children=[
|
|
html.Tr([html.Td("No positions yet", colSpan=6, style={'textAlign': 'center', 'padding': '4px', 'fontSize': '12px'})])
|
|
])
|
|
], style={
|
|
'width': '100%',
|
|
'borderCollapse': 'collapse',
|
|
'fontSize': '12px',
|
|
'backgroundColor': '#222222',
|
|
'color': 'white',
|
|
'marginBottom': '10px'
|
|
})
|
|
], style={'marginBottom': '10px'}),
|
|
|
|
# Chart area
|
|
dcc.Graph(
|
|
id='real-time-chart',
|
|
style={'height': 'calc(100vh - 250px)'}, # Adjusted to account for header and table
|
|
config={
|
|
'displayModeBar': True,
|
|
'scrollZoom': True,
|
|
'modeBarButtonsToRemove': ['lasso2d', 'select2d']
|
|
}
|
|
),
|
|
|
|
# Interval selector
|
|
html.Div([
|
|
html.Button('1m', id='1m-interval', n_clicks=0, style=active_button_style if self.current_interval == '1m' else button_style),
|
|
html.Button('5m', id='5m-interval', n_clicks=0, style=active_button_style if self.current_interval == '5m' else button_style),
|
|
html.Button('15m', id='15m-interval', n_clicks=0, style=active_button_style if self.current_interval == '15m' else button_style),
|
|
html.Button('1h', id='1h-interval', n_clicks=0, style=active_button_style if self.current_interval == '1h' else button_style),
|
|
html.Button('4h', id='4h-interval', n_clicks=0, style=active_button_style if self.current_interval == '4h' else button_style),
|
|
html.Button('1d', id='1d-interval', n_clicks=0, style=active_button_style if self.current_interval == '1d' else button_style),
|
|
], style={'textAlign': 'center', 'marginTop': '10px'}),
|
|
|
|
# Interval component for automatic updates
|
|
dcc.Interval(
|
|
id='chart-interval',
|
|
interval=300, # Refresh every 300ms for better real-time updates
|
|
n_intervals=0
|
|
)
|
|
], style={
|
|
'backgroundColor': '#121212',
|
|
'padding': '20px',
|
|
'color': 'white',
|
|
'height': '100vh',
|
|
'boxSizing': 'border-box'
|
|
})
|
|
|
|
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'
|
|
}),
|
|
|
|
# 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):
|
|
"""Set up the callback for interval selection buttons"""
|
|
@self.app.callback(
|
|
[
|
|
Output('1m-interval', 'style'),
|
|
Output('5m-interval', 'style'),
|
|
Output('15m-interval', 'style'),
|
|
Output('1h-interval', 'style'),
|
|
Output('4h-interval', 'style'),
|
|
Output('1d-interval', 'style')
|
|
],
|
|
[
|
|
Input('1m-interval', 'n_clicks'),
|
|
Input('5m-interval', 'n_clicks'),
|
|
Input('15m-interval', 'n_clicks'),
|
|
Input('1h-interval', 'n_clicks'),
|
|
Input('4h-interval', 'n_clicks'),
|
|
Input('1d-interval', 'n_clicks')
|
|
]
|
|
)
|
|
def update_interval_buttons(n1, n5, n15, n1h, n4h, n1d):
|
|
ctx = callback_context
|
|
|
|
# Default styles (all inactive)
|
|
styles = {
|
|
'1m': button_style.copy(),
|
|
'5m': button_style.copy(),
|
|
'15m': button_style.copy(),
|
|
'1h': button_style.copy(),
|
|
'4h': button_style.copy(),
|
|
'1d': button_style.copy()
|
|
}
|
|
|
|
# If no button clicked yet, use default interval
|
|
if not ctx.triggered:
|
|
styles[self.current_interval] = active_button_style.copy()
|
|
return [styles['1m'], styles['5m'], styles['15m'], styles['1h'], styles['4h'], styles['1d']]
|
|
|
|
# Get the button ID that was clicked
|
|
button_id = ctx.triggered[0]['prop_id'].split('.')[0]
|
|
|
|
# Map button ID to interval
|
|
interval_map = {
|
|
'1m-interval': '1m',
|
|
'5m-interval': '5m',
|
|
'15m-interval': '15m',
|
|
'1h-interval': '1h',
|
|
'4h-interval': '4h',
|
|
'1d-interval': '1d'
|
|
}
|
|
|
|
# Update the current interval based on clicked button
|
|
self.current_interval = interval_map.get(button_id, self.current_interval)
|
|
|
|
# Set active style for selected interval
|
|
styles[self.current_interval] = active_button_style.copy()
|
|
|
|
# Update the chart with the new interval
|
|
self._update_chart()
|
|
|
|
return [styles['1m'], styles['5m'], styles['15m'], styles['1h'], styles['4h'], styles['1d']]
|
|
|
|
def _setup_chart_callback(self):
|
|
"""Set up the callback for the chart updates"""
|
|
@self.app.callback(
|
|
Output('real-time-chart', 'figure'),
|
|
[Input('chart-interval', 'n_intervals')]
|
|
)
|
|
def update_chart(n_intervals):
|
|
try:
|
|
# Create the main figure if it doesn't exist yet
|
|
if self.fig is None:
|
|
self._initialize_chart()
|
|
|
|
# Update the chart data
|
|
self._update_chart()
|
|
|
|
return self.fig
|
|
except Exception as e:
|
|
logger.error(f"Error updating chart: {str(e)}")
|
|
import traceback
|
|
logger.error(traceback.format_exc())
|
|
|
|
# Return empty figure on error
|
|
return {
|
|
'data': [],
|
|
'layout': {
|
|
'title': 'Error updating chart',
|
|
'annotations': [{
|
|
'text': str(e),
|
|
'showarrow': False,
|
|
'font': {'color': 'red'}
|
|
}]
|
|
}
|
|
}
|
|
|
|
def _setup_position_list_callback(self):
|
|
"""Set up the callback for the position list"""
|
|
@self.app.callback(
|
|
Output('position-list', 'children'),
|
|
[Input('chart-interval', 'n_intervals')]
|
|
)
|
|
def update_position_list(n):
|
|
if not self.positions:
|
|
return [html.Tr([html.Td("No positions yet", colSpan=6)])]
|
|
return self._get_position_list_rows()
|
|
|
|
def _setup_trading_status_callback(self):
|
|
"""Set up the callback for the trading status fields"""
|
|
@self.app.callback(
|
|
[
|
|
Output('current-signal-value', 'children'),
|
|
Output('current-position-value', 'children'),
|
|
Output('current-balance-value', 'children'),
|
|
Output('current-pnl-value', 'children'),
|
|
Output('current-signal-value', 'style'),
|
|
Output('current-position-value', 'style')
|
|
],
|
|
[Input('chart-interval', 'n_intervals')]
|
|
)
|
|
def update_trading_status(n):
|
|
# Get the current signal
|
|
current_signal = "NONE"
|
|
signal_style = {'color': 'white'}
|
|
|
|
if hasattr(self, 'last_action') and self.last_action:
|
|
current_signal = self.last_action
|
|
if current_signal == "BUY":
|
|
signal_style = {'color': 'green', 'fontWeight': 'bold'}
|
|
elif current_signal == "SELL":
|
|
signal_style = {'color': 'red', 'fontWeight': 'bold'}
|
|
|
|
# Get the current position
|
|
current_position = "NONE"
|
|
position_style = {'color': 'white'}
|
|
|
|
# Check if we have any open positions
|
|
open_positions = [p for p in self.positions if p.status == "OPEN"]
|
|
if open_positions:
|
|
current_position = f"{open_positions[0].action} {open_positions[0].amount:.4f}"
|
|
if open_positions[0].action == "BUY":
|
|
position_style = {'color': 'green', 'fontWeight': 'bold'}
|
|
else:
|
|
position_style = {'color': 'red', 'fontWeight': 'bold'}
|
|
|
|
# Get the current balance and session PnL
|
|
current_balance = f"${self.balance:.2f}" if hasattr(self, 'balance') else "$0.00"
|
|
|
|
# Calculate session PnL
|
|
session_pnl = 0
|
|
for position in self.positions:
|
|
if position.status == "CLOSED":
|
|
session_pnl += position.pnl
|
|
|
|
# Format PnL with color
|
|
pnl_text = f"${session_pnl:.2f}"
|
|
|
|
return current_signal, current_position, current_balance, pnl_text, signal_style, position_style
|
|
|
|
def _add_manual_trade_inputs(self):
|
|
# Add manual trade inputs
|
|
self.app.layout.children.append(
|
|
html.Div([
|
|
html.H3("Add Manual Trade"),
|
|
dcc.Input(id='manual-price', type='number', placeholder='Price'),
|
|
dcc.Input(id='manual-volume', type='number', placeholder='Volume'),
|
|
dcc.Input(id='manual-pnl', type='number', placeholder='PnL'),
|
|
dcc.Input(id='manual-action', type='text', placeholder='Action'),
|
|
html.Button('Add Trade', id='add-manual-trade')
|
|
])
|
|
)
|
|
|
|
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}")
|
|
# 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(f"WebSocket connection lost for {self.symbol}, breaking loop")
|
|
break
|
|
|
|
data = await ws.receive()
|
|
if data:
|
|
if data.get('type') == 'kline':
|
|
# Use kline data directly for candlestick
|
|
trade_data = {
|
|
'timestamp': data['timestamp'],
|
|
'price': data['price'],
|
|
'volume': data['volume'],
|
|
'open': data['open'],
|
|
'high': data['high'],
|
|
'low': data['low']
|
|
}
|
|
logger.debug(f"Received kline data: {data}")
|
|
else:
|
|
# Use trade data
|
|
trade_data = {
|
|
'timestamp': data['timestamp'],
|
|
'price': data['price'],
|
|
'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
|
|
|
|
# Also update the old candlestick data for backward compatibility
|
|
# Add check to ensure the candlestick_data attribute exists before using it
|
|
if hasattr(self, 'candlestick_data'):
|
|
self.candlestick_data.update_from_trade(trade_data)
|
|
|
|
# Log tick counts periodically
|
|
current_time = time.time()
|
|
if current_time - last_tick_count_log >= 10: # Log every 10 seconds
|
|
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
|
|
|
|
# Check if ticks are being converted to candles
|
|
if len(self.tick_storage.ticks) > 0:
|
|
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 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(f"Waiting 5 seconds before reconnecting {self.symbol} WebSocket...")
|
|
await asyncio.sleep(5)
|
|
|
|
def _run_app(self):
|
|
"""Run the Dash app"""
|
|
try:
|
|
logger.info(f"Starting Dash app for {self.symbol}")
|
|
# Updated to use app.run instead of app.run_server (which is deprecated)
|
|
self.app.run(debug=False, use_reloader=False, port=8050)
|
|
except Exception as e:
|
|
logger.error(f"Error running Dash app: {str(e)}")
|
|
logger.error(traceback.format_exc())
|
|
|
|
return
|
|
|
|
def add_trade(self, price, timestamp=None, pnl=None, amount=0.1, action="BUY", trade_type="MARKET"):
|
|
"""Add a trade to the chart
|
|
|
|
Args:
|
|
price: Trade price
|
|
timestamp: Trade timestamp (datetime or milliseconds)
|
|
pnl: Profit and Loss (for SELL trades)
|
|
amount: Trade amount
|
|
action: Trade action (BUY or SELL)
|
|
trade_type: Trade type (MARKET, LIMIT, etc.)
|
|
"""
|
|
try:
|
|
# Convert timestamp to datetime if it's a number
|
|
if timestamp is None:
|
|
timestamp = datetime.now()
|
|
elif isinstance(timestamp, (int, float)):
|
|
timestamp = datetime.fromtimestamp(timestamp / 1000)
|
|
|
|
# Process the trade based on action
|
|
if action == "BUY":
|
|
# Create a new position
|
|
position = Position(
|
|
action="BUY",
|
|
entry_price=price,
|
|
amount=amount,
|
|
timestamp=timestamp
|
|
)
|
|
self.positions.append(position)
|
|
|
|
# Update last action
|
|
self.last_action = "BUY"
|
|
|
|
elif action == "SELL":
|
|
# Find an open BUY position to close, or create a new SELL position
|
|
open_buy_position = None
|
|
for pos in self.positions:
|
|
if pos.status == "OPEN" and pos.action == "BUY":
|
|
open_buy_position = pos
|
|
break
|
|
|
|
if open_buy_position:
|
|
# Close the position
|
|
pnl_value = open_buy_position.close(price, timestamp)
|
|
|
|
# Update balance
|
|
self.balance += pnl_value
|
|
|
|
# If pnl was provided, use it instead
|
|
if pnl is not None:
|
|
open_buy_position.pnl = pnl
|
|
self.balance = self.balance - pnl_value + pnl
|
|
|
|
else:
|
|
# Create a standalone SELL position
|
|
position = Position(
|
|
action="SELL",
|
|
entry_price=price,
|
|
amount=amount,
|
|
timestamp=timestamp
|
|
)
|
|
|
|
# Set it as closed with the same price
|
|
position.close(price, timestamp)
|
|
|
|
# Set PnL if provided
|
|
if pnl is not None:
|
|
position.pnl = pnl
|
|
self.balance += pnl
|
|
|
|
self.positions.append(position)
|
|
|
|
# Update last action
|
|
self.last_action = "SELL"
|
|
|
|
# Log the trade
|
|
logger.info(f"Added {action} trade: price={price}, amount={amount}, time={timestamp}, PnL={pnl}")
|
|
|
|
# Trigger more frequent chart updates for immediate visibility
|
|
if hasattr(self, 'fig') and self.fig is not None:
|
|
self._update_chart()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error adding trade: {str(e)}")
|
|
import traceback
|
|
logger.error(traceback.format_exc())
|
|
|
|
def update_trading_info(self, signal=None, position=None, balance=None, pnl=None):
|
|
"""Update the current trading information to be displayed on the chart
|
|
|
|
Args:
|
|
signal: Current signal (BUY, SELL, HOLD)
|
|
position: Current position size
|
|
balance: Current session balance
|
|
pnl: Current session PnL
|
|
"""
|
|
if signal is not None:
|
|
if signal in ['BUY', 'SELL', 'HOLD']:
|
|
self.current_signal = signal
|
|
self.signal_time = datetime.now()
|
|
else:
|
|
logger.warning(f"Invalid signal type: {signal}")
|
|
|
|
if position is not None:
|
|
self.current_position = position
|
|
|
|
if balance is not None:
|
|
self.session_balance = balance
|
|
|
|
if pnl is not None:
|
|
self.session_pnl = pnl
|
|
|
|
logger.debug(f"Updated trading info: Signal={self.current_signal}, Position={self.current_position}, Balance=${self.session_balance:.2f}, PnL={self.session_pnl:.4f}")
|
|
|
|
def _get_position_list_rows(self):
|
|
"""Generate rows for the position table"""
|
|
if not self.positions:
|
|
return [html.Tr([html.Td("No positions yet", colSpan=6)])]
|
|
|
|
position_rows = []
|
|
|
|
# Sort positions by time (most recent first)
|
|
sorted_positions = sorted(self.positions,
|
|
key=lambda x: x.timestamp if hasattr(x, 'timestamp') else datetime.now(),
|
|
reverse=True)
|
|
|
|
# Take only the most recent 5 positions
|
|
for position in sorted_positions[:5]:
|
|
# Format time
|
|
time_obj = position.timestamp if hasattr(position, 'timestamp') else datetime.now()
|
|
if isinstance(time_obj, datetime):
|
|
# If trade is from a different day, include the date
|
|
today = datetime.now().date()
|
|
if time_obj.date() == today:
|
|
time_str = time_obj.strftime('%H:%M:%S')
|
|
else:
|
|
time_str = time_obj.strftime('%m-%d %H:%M:%S')
|
|
else:
|
|
time_str = str(time_obj)
|
|
|
|
# Format prices with proper decimal places
|
|
entry_price = position.entry_price if hasattr(position, 'entry_price') else 'N/A'
|
|
if isinstance(entry_price, (int, float)):
|
|
entry_price_str = f"${entry_price:.6f}"
|
|
else:
|
|
entry_price_str = str(entry_price)
|
|
|
|
# For exit price, use close_price for closed positions or current market price for open ones
|
|
if position.status == "CLOSED" and hasattr(position, 'exit_price'):
|
|
exit_price = position.exit_price
|
|
else:
|
|
exit_price = self.tick_storage.get_latest_price() if position.status == "OPEN" else 'N/A'
|
|
|
|
if isinstance(exit_price, (int, float)):
|
|
exit_price_str = f"${exit_price:.6f}"
|
|
else:
|
|
exit_price_str = str(exit_price)
|
|
|
|
# Format amount
|
|
amount = position.amount if hasattr(position, 'amount') else 0.1
|
|
amount_str = f"{amount:.4f} BTC"
|
|
|
|
# Format PnL
|
|
if position.status == "CLOSED":
|
|
pnl = position.pnl if hasattr(position, 'pnl') else 0
|
|
pnl_str = f"${pnl:.2f}"
|
|
pnl_color = '#00FF00' if pnl >= 0 else '#FF0000'
|
|
elif position.status == "OPEN" and position.action == "BUY":
|
|
# Calculate unrealized PnL for open positions
|
|
if isinstance(exit_price, (int, float)) and isinstance(entry_price, (int, float)):
|
|
unrealized_pnl = (exit_price - entry_price) * amount
|
|
pnl_str = f"${unrealized_pnl:.2f} (unrealized)"
|
|
pnl_color = '#00FF00' if unrealized_pnl >= 0 else '#FF0000'
|
|
else:
|
|
pnl_str = 'N/A'
|
|
pnl_color = '#FFFFFF'
|
|
else:
|
|
pnl_str = 'N/A'
|
|
pnl_color = '#FFFFFF'
|
|
|
|
# Set action/status color and text
|
|
if position.status == 'OPEN':
|
|
status_color = '#00AAFF' # Blue for open positions
|
|
status_text = f"OPEN ({position.action})"
|
|
elif position.status == 'CLOSED':
|
|
if hasattr(position, 'pnl') and isinstance(position.pnl, (int, float)):
|
|
status_color = '#00FF00' if position.pnl >= 0 else '#FF0000' # Green/Red based on profit
|
|
else:
|
|
status_color = '#FFCC00' # Yellow if PnL unknown
|
|
status_text = "CLOSED"
|
|
else:
|
|
status_color = '#00FF00' if position.action == 'BUY' else '#FF0000'
|
|
status_text = position.action
|
|
|
|
# Create table row with more compact styling
|
|
position_rows.append(html.Tr([
|
|
html.Td(status_text, style={'color': status_color, 'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px'}),
|
|
html.Td(amount_str, style={'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px'}),
|
|
html.Td(entry_price_str, style={'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px'}),
|
|
html.Td(exit_price_str, style={'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px'}),
|
|
html.Td(pnl_str, style={'color': pnl_color, 'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px'}),
|
|
html.Td(time_str, style={'padding': '4px 8px', 'border': '1px solid #444', 'fontSize': '12px'})
|
|
]))
|
|
|
|
return position_rows
|
|
|
|
def _initialize_chart(self):
|
|
"""Initialize the chart figure"""
|
|
# Create a figure with subplots for price and volume
|
|
self.fig = make_subplots(
|
|
rows=2,
|
|
cols=1,
|
|
shared_xaxes=True,
|
|
vertical_spacing=0.03,
|
|
row_heights=[0.8, 0.2],
|
|
subplot_titles=(f"{self.symbol} Price Chart", "Volume")
|
|
)
|
|
|
|
# Set up initial empty traces
|
|
self.fig.add_trace(
|
|
go.Candlestick(
|
|
x=[], open=[], high=[], low=[], close=[],
|
|
name='Price',
|
|
increasing={'line': {'color': '#26A69A', 'width': 1}, 'fillcolor': '#26A69A'},
|
|
decreasing={'line': {'color': '#EF5350', 'width': 1}, 'fillcolor': '#EF5350'}
|
|
),
|
|
row=1, col=1
|
|
)
|
|
|
|
# Add volume trace
|
|
self.fig.add_trace(
|
|
go.Bar(
|
|
x=[], y=[],
|
|
name='Volume',
|
|
marker={'color': '#888888'}
|
|
),
|
|
row=2, col=1
|
|
)
|
|
|
|
# Add empty traces for buy/sell markers
|
|
self.fig.add_trace(
|
|
go.Scatter(
|
|
x=[], y=[],
|
|
mode='markers',
|
|
name='BUY',
|
|
marker=dict(
|
|
symbol='triangle-up',
|
|
size=12,
|
|
color='rgba(0,255,0,0.8)',
|
|
line=dict(width=1, color='darkgreen')
|
|
),
|
|
showlegend=True
|
|
),
|
|
row=1, col=1
|
|
)
|
|
|
|
self.fig.add_trace(
|
|
go.Scatter(
|
|
x=[], y=[],
|
|
mode='markers',
|
|
name='SELL',
|
|
marker=dict(
|
|
symbol='triangle-down',
|
|
size=12,
|
|
color='rgba(255,0,0,0.8)',
|
|
line=dict(width=1, color='darkred')
|
|
),
|
|
showlegend=True
|
|
),
|
|
row=1, col=1
|
|
)
|
|
|
|
# Update layout
|
|
self.fig.update_layout(
|
|
title=f"{self.symbol} Real-Time Trading Chart",
|
|
title_x=0.5,
|
|
template='plotly_dark',
|
|
paper_bgcolor='rgba(0,0,0,0)',
|
|
plot_bgcolor='rgba(25,25,50,1)',
|
|
height=800,
|
|
xaxis_rangeslider_visible=False,
|
|
legend=dict(
|
|
orientation="h",
|
|
yanchor="bottom",
|
|
y=1.02,
|
|
xanchor="center",
|
|
x=0.5
|
|
)
|
|
)
|
|
|
|
# Update axes styling
|
|
self.fig.update_xaxes(
|
|
showgrid=True,
|
|
gridwidth=1,
|
|
gridcolor='rgba(128,128,128,0.2)',
|
|
zeroline=False
|
|
)
|
|
|
|
self.fig.update_yaxes(
|
|
showgrid=True,
|
|
gridwidth=1,
|
|
gridcolor='rgba(128,128,128,0.2)',
|
|
zeroline=False
|
|
)
|
|
|
|
# Do an initial update to populate the chart
|
|
self._update_chart()
|
|
|
|
def _update_chart(self):
|
|
"""Update the chart with the latest data"""
|
|
try:
|
|
# Get candlesticks data for the current interval
|
|
df = self.tick_storage.get_candles(interval=self.current_interval)
|
|
|
|
if df is None or df.empty:
|
|
logger.warning(f"No candle data available for {self.current_interval}")
|
|
return
|
|
|
|
# Limit the number of candles to display (show 500 for context)
|
|
df = df.tail(500)
|
|
|
|
# Update candlestick data
|
|
self.fig.update_traces(
|
|
x=df.index,
|
|
open=df['open'],
|
|
high=df['high'],
|
|
low=df['low'],
|
|
close=df['close'],
|
|
selector=dict(type='candlestick')
|
|
)
|
|
|
|
# Update volume bars with colors based on price movement
|
|
colors = ['rgba(0,255,0,0.5)' if close >= open else 'rgba(255,0,0,0.5)'
|
|
for open, close in zip(df['open'], df['close'])]
|
|
|
|
self.fig.update_traces(
|
|
x=df.index,
|
|
y=df['volume'],
|
|
marker_color=colors,
|
|
selector=dict(type='bar')
|
|
)
|
|
|
|
# Calculate y-axis range with padding for better visibility
|
|
if len(df) > 0:
|
|
low_min = df['low'].min()
|
|
high_max = df['high'].max()
|
|
price_range = high_max - low_min
|
|
y_min = low_min - (price_range * 0.05) # 5% padding below
|
|
y_max = high_max + (price_range * 0.05) # 5% padding above
|
|
|
|
# Update y-axis range
|
|
self.fig.update_yaxes(range=[y_min, y_max], row=1, col=1)
|
|
|
|
# Update Buy/Sell markers
|
|
if hasattr(self, 'positions') and self.positions:
|
|
# Collect buy and sell points
|
|
buy_times = []
|
|
buy_prices = []
|
|
sell_times = []
|
|
sell_prices = []
|
|
|
|
for position in self.positions:
|
|
# Handle buy trades
|
|
if position.action == "BUY":
|
|
buy_times.append(position.timestamp)
|
|
buy_prices.append(position.entry_price)
|
|
|
|
# Handle sell trades or closed positions
|
|
if position.status == "CLOSED" and hasattr(position, 'exit_timestamp') and hasattr(position, 'exit_price'):
|
|
sell_times.append(position.exit_timestamp)
|
|
sell_prices.append(position.exit_price)
|
|
|
|
# Update buy markers trace
|
|
self.fig.update_traces(
|
|
x=buy_times,
|
|
y=buy_prices,
|
|
selector=dict(name='BUY')
|
|
)
|
|
|
|
# Update sell markers trace
|
|
self.fig.update_traces(
|
|
x=sell_times,
|
|
y=sell_prices,
|
|
selector=dict(name='SELL')
|
|
)
|
|
|
|
# Update chart title with the current interval
|
|
self.fig.update_layout(
|
|
title=f"{self.symbol} Real-Time Chart ({self.current_interval})"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in _update_chart: {str(e)}")
|
|
import traceback
|
|
logger.error(traceback.format_exc())
|
|
|
|
async def main():
|
|
global charts # Make charts globally accessible for NN integration
|
|
symbols = ["ETH/USDT", "ETH/USDT"]
|
|
logger.info(f"Starting application for symbols: {symbols}")
|
|
|
|
# Initialize neural network if enabled
|
|
if NN_ENABLED:
|
|
logger.info("Initializing Neural Network integration...")
|
|
if setup_neural_network():
|
|
logger.info("Neural Network integration initialized successfully")
|
|
else:
|
|
logger.warning("Neural Network integration failed to initialize")
|
|
|
|
charts = []
|
|
websocket_tasks = []
|
|
|
|
# Create a chart and websocket task for each symbol
|
|
for symbol in symbols:
|
|
chart = RealTimeChart(symbol)
|
|
charts.append(chart)
|
|
websocket_tasks.append(asyncio.create_task(chart.start_websocket()))
|
|
|
|
# Run Dash in a separate thread to not block the event loop
|
|
server_threads = []
|
|
for i, chart in enumerate(charts):
|
|
port = 8050 + i # Use different ports for each chart
|
|
logger.info(f"Starting chart for {chart.symbol} on port {port}")
|
|
thread = Thread(target=lambda c=chart, p=port: c.run(port=p)) # Ensure correct port is passed
|
|
thread.daemon = True
|
|
thread.start()
|
|
server_threads.append(thread)
|
|
logger.info(f"Thread started for {chart.symbol} on port {port}")
|
|
|
|
try:
|
|
# Keep the main task running
|
|
while True:
|
|
await asyncio.sleep(1)
|
|
except KeyboardInterrupt:
|
|
logger.info("Shutting down...")
|
|
except Exception as e:
|
|
logger.error(f"Unexpected error: {str(e)}")
|
|
finally:
|
|
for task in websocket_tasks:
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
asyncio.run(main())
|
|
except KeyboardInterrupt:
|
|
logger.info("Application terminated by user")
|
|
|