import asyncio
import datetime
import json
import os
import random
import sys
import threading
import time
import django
import pytz
import websockets
import ccxt.async_support as ccxt  # Use the async support version of ccxt

# Set the event loop policy for Windows
if sys.platform.startswith('win'):
    asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

# Set the environment variable to point to your project’s settings
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'binancebot.settings')

# Initialize Django
django.setup()

import concurrent.futures
from asgiref.sync import sync_to_async
from concurrent.futures import ProcessPoolExecutor, as_completed
import pandas as pd
import numpy as np
import talib as ta
from django.utils import timezone
from signals.models import Signal  # Adjust this import based on your project structure
from multiprocessing import Process, Event
from loguru import logger
from channels.db import database_sync_to_async
import traceback

# Remove all existing handlers (including the default one)
logger.remove()

# Configure your custom handlers
logger.add("trading_bot.log", rotation="20 MB", retention="2 days", level="DEBUG",
           format="{time} | {level} | {message}")  # File logging
logger.add(sys.stdout, level="DEBUG")  # Console logging

class CombinedSignalGenerator:
    def __init__(self, symbols, timeframes=['5m', '15m', '1h']):
        self.symbols = symbols  # List of symbols as provided by ccxt
        self.timeframes = [tf.lower() for tf in timeframes]
        self.exchange = ccxt.binanceusdm({'enableRateLimit': True})
        self.dfs = {}  # DataFrames will be initialized after loading historical data
        self.data_lock = asyncio.Lock()
        self.symbol_map = {}
        self.streams = []
        self.url = ""

        # Define the directory to store historical data
        self.data_directory = "historical_data"
        # Create the directory if it doesn't exist
        if not os.path.exists(self.data_directory):
            os.makedirs(self.data_directory)

        logger.debug(f"Initialized CombinedSignalGenerator with symbols: {self.symbols}, timeframes: {self.timeframes}")

    async def initialize_exchange(self):
        # Load markets
        await self.exchange.load_markets()
        # Prepare DataFrames and symbol mappings
        self.dfs = {symbol: {tf: pd.DataFrame() for tf in self.timeframes} for symbol in self.symbols}
        for symbol in self.symbols:
            market = self.exchange.market(symbol)
            ws_symbol = market['id'].lower()  # WebSocket symbols are in lowercase
            self.symbol_map[ws_symbol] = symbol  # Map WebSocket symbol to ccxt symbol

            for timeframe in self.timeframes:
                self.streams.append(f"{ws_symbol}@kline_{timeframe}")

        # Limit the number of streams per connection to prevent exceeding URL length limits
        max_streams_per_connection = 100  # Adjust based on testing
        self.stream_urls = []
        for i in range(0, len(self.streams), max_streams_per_connection):
            stream_subset = self.streams[i:i + max_streams_per_connection]
            url = f"wss://fstream.binance.com/stream?streams={'/'.join(stream_subset)}"
            self.stream_urls.append(url)

        logger.debug(f"WebSocket URLs: {self.stream_urls}")

    async def fetch_historical_data(self):
        # Limit concurrency to prevent rate limiting
        semaphore = asyncio.Semaphore(3)
        tasks = []
        for symbol in self.symbols:
            for timeframe in self.timeframes:
                tasks.append(self.fetch_symbol_historical_data(symbol, timeframe, semaphore))
        await asyncio.gather(*tasks)

    async def fetch_symbol_historical_data(self, symbol, timeframe, semaphore):
        async with semaphore:
            try:
                # Define a filename for caching based on the symbol and timeframe
                filename = f"historical_data_{symbol.replace('/', '').replace(':', '')}_{timeframe}.csv"
                filepath = os.path.join(self.data_directory, filename)

                # Check if the file exists
                if os.path.exists(filepath):
                    # Load the existing data
                    df = pd.read_csv(filepath, parse_dates=['timestamp'])
                    if df['timestamp'].dt.tz is None:
                        # Localize to 'UTC' and convert to 'Africa/Nairobi'
                        df['timestamp'] = df['timestamp'].dt.tz_localize('UTC').dt.tz_convert('Africa/Nairobi')
                    else:
                        df['timestamp'] = df['timestamp'].dt.tz_convert('Africa/Nairobi')

                    logger.info(f"Loaded historical data for {symbol} {timeframe} from {filename}")
                    # Get the most recent timestamp
                    last_timestamp = df['timestamp'].max()
                    # Fetch data from the last timestamp onward
                    since = int(last_timestamp.tz_convert('UTC').timestamp() * 1000) + 1
                    logger.info(f"Fetching new data for {symbol} {timeframe} since {last_timestamp}")
                else:
                    # File doesn't exist, fetch recent historical data
                    logger.info(f"No historical data file found for {symbol} {timeframe}. Fetching recent data.")
                    df = pd.DataFrame()
                    since = None  # Fetch recent data

                # Fetch historical bars
                limit = None  # Fetch as much data as possible within API constraints
                bars = await self.exchange.fetch_ohlcv(
                    symbol,
                    timeframe=timeframe,
                    since=since,
                    limit=1500  # Binance allows up to 1500
                )

                if bars:
                    new_df = pd.DataFrame(bars, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume'])
                    new_df['timestamp'] = pd.to_datetime(new_df['timestamp'], unit='ms', utc=True)
                    new_df['timestamp'] = new_df['timestamp'].dt.tz_convert('Africa/Nairobi')
                    df = pd.concat([df, new_df], ignore_index=True)
                    df.drop_duplicates(subset='timestamp', keep='last', inplace=True)
                    df.sort_values(by='timestamp', inplace=True)
                    df.reset_index(drop=True, inplace=True)
                    # Keep only the recent data to limit memory usage
                    df = df.tail(2000)  # Adjust as needed
                    # Save the updated data to CSV
                    df.to_csv(filepath, index=False)
                    logger.info(f"Fetched and saved historical data for {symbol} {timeframe} successfully.")
                else:
                    logger.info(f"No new data fetched for {symbol} {timeframe}.")
                async with self.data_lock:
                    self.dfs[symbol][timeframe] = df
            except Exception as e:
                logger.error(f"Error fetching historical data for {symbol} {timeframe}: {e}\n{traceback.format_exc()}")
                async with self.data_lock:
                    self.dfs[symbol][timeframe] = pd.DataFrame()

    async def fetch_realtime_data(self):
        max_retries = 3

        
        async def handle_websocket(url):
            retry_count = 0
            max_retries = 4  # Adjust as needed
            backoff = 5  # Initial backoff time in seconds
            while retry_count < max_retries:
                try:
                    async with websockets.connect(
                        url,
                        ping_interval=20,  # Send a ping every 20 seconds
                        ping_timeout=20,   # Wait 20 seconds for a pong response
                        close_timeout=10   # Wait 10 seconds for the connection to close gracefully
                    ) as websocket:
                        logger.info(f"Connected to websocket: {url}")
                        retry_count = 0  # Reset retry count on successful connection
                        backoff = 5      # Reset backoff time

                        while True:
                            try:
                                message = await websocket.recv()
                                data = json.loads(message)
                                if 'data' not in data:
                                    continue
                                # Handle both single and combined streams
                                if isinstance(data['data'], dict):
                                    kline_data_list = [data['data']]
                                else:
                                    kline_data_list = data['data']

                                for item in kline_data_list:
                                    kline = item['k']
                                    raw_symbol = kline['s']  # Binance symbol, like 'BTCUSDT'
                                    symbol_formatted = raw_symbol.lower()
                                    symbol = self.symbol_map.get(symbol_formatted)
                                    if not symbol:
                                        # Skip symbols not in our mapping
                                        continue
                                    timeframe = kline['i']
                                    if timeframe not in self.timeframes:
                                        continue  # Skip timeframes we're not tracking
                                    is_kline_closed = kline['x']  # 'x' is True when the kline is closed
                                    if not is_kline_closed:
                                        continue  # Skip if the kline is not closed (still forming)
                                    kline_data = {
                                        'timestamp': pd.to_datetime(kline['t'], unit='ms', utc=True).tz_convert('Africa/Nairobi'),
                                        'open': float(kline['o']),
                                        'high': float(kline['h']),
                                        'low': float(kline['l']),
                                        'close': float(kline['c']),
                                        'volume': float(kline['v']),
                                    }
                                    logger.debug(f"Received kline data for {symbol} {timeframe} at {kline_data['timestamp']}")
                                    await self.collect_data(symbol, timeframe, kline_data)
                                    await self.generate_and_save_signals(symbol, timeframe)
                            except websockets.ConnectionClosed as e:
                                logger.error(f"Websocket connection closed: {e}")
                                break
                            except Exception as e:
                                logger.error(f"Error in websocket message handling: {e}\n{traceback.format_exc()}")
                except Exception as e:
                    logger.error(f"Websocket connection error: {e}\n{traceback.format_exc()}")
                    retry_count += 1
                    if retry_count < max_retries:
                        logger.info(f"Retrying websocket connection (Attempt {retry_count + 1}/{max_retries})")
                        sleep_time = backoff + random.uniform(0, 1)
                        logger.info(f"Retrying in {sleep_time:.2f} seconds (Attempt {retry_count}/{max_retries})")   
                        await asyncio.sleep(5)  # Wait before retrying
                        backoff *= 2  # Exponential backoff
                    else:
                        logger.error(f"Max retries reached. Exiting websocket connection for url: {url}")
                        break

        # Run websocket connections concurrently
        tasks = [handle_websocket(url) for url in self.stream_urls]
        await asyncio.gather(*tasks)

    async def collect_data(self, symbol, timeframe, kline_data):
        async with self.data_lock:
            if symbol not in self.dfs or timeframe not in self.dfs[symbol]:
                logger.warning(f"Symbol {symbol} or timeframe {timeframe} not found in dfs. Skipping update.")
                logger.debug(f"Available symbols: {list(self.dfs.keys())}")
                return

            df = self.dfs[symbol][timeframe]
            # Before adding the new data, check if it's new
            if not df.empty and kline_data['timestamp'] <= df['timestamp'].iloc[-1]:
                logger.debug(f"Received old kline data for {symbol} {timeframe} at {kline_data['timestamp']}. Skipping.")
                return
            # Append new data
            df = pd.concat([df, pd.DataFrame([kline_data])], ignore_index=True)
            df.drop_duplicates(subset='timestamp', keep='last', inplace=True)
            df.sort_values(by='timestamp', inplace=True)
            # Keep only the recent data to limit memory usage
            df = df.tail(2000)  # Adjust as needed
            self.dfs[symbol][timeframe] = df
            logger.debug(f"Collected data for {symbol} {timeframe} at {kline_data['timestamp']}")
            # Save the updated data to CSV
            filename = f"historical_data_{symbol.replace('/', '').replace(':', '')}_{timeframe}.csv"
            filepath = os.path.join(self.data_directory, filename)
            df.to_csv(filepath, index=False)

    async def generate_and_save_signals(self, symbol, timeframe):
        # For each symbol and timeframe, we need to analyze using multiple analyses
        analysis_results = {}

        async with self.data_lock:
            if symbol not in self.dfs or timeframe not in self.dfs[symbol]:
                logger.warning(f"Symbol {symbol} or timeframe {timeframe} not found in dfs during signal generation.")
                return
            df = self.dfs[symbol][timeframe].copy()
        if not df.empty:
            # Perform analysis set 1
            analysis1_result = self.generate_signals(symbol, timeframe, df, analysis_set='first')
            analysis_results['Analysis1'] = analysis1_result

            # Perform analysis set 2
            analysis2_result = self.generate_signals(symbol, timeframe, df, analysis_set='second')
            analysis_results['Analysis2'] = analysis2_result

            # Perform price action analysis
            price_action_result = self.price_action_analysis(df)
            analysis_results['Price_Action'] = price_action_result

            # Perform chart patterns analysis
            chart_pattern_result = self.chart_patterns_analysis(df)
            analysis_results['Chart_Patterns'] = chart_pattern_result

            # Perform volume analysis
            volume_analysis_result = self.volume_analysis(df)
            analysis_results['Volume_Analysis'] = volume_analysis_result

            # Perform machine learning analysis
            ml_analysis_result = self.machine_learning_analysis(df)
            analysis_results['Machine_Learning'] = ml_analysis_result

            # Combine the analyses to determine the signal strength
            final_signal_row = self.combine_analysis_results(analysis_results)

            if final_signal_row is not None:
                if final_signal_row['Final_Signal'].iloc[0] == 'NO_SIGNAL':
                    # If no bullish or bearish signal, perform sideways analysis
                    sideways_result = self.generate_sideways_signal(symbol, timeframe, df)
                    if sideways_result is not None and sideways_result['Signal'].iloc[0] == 'SIDEWAYS':
                        # Update the final_signal_row
                        final_signal_row['Final_Signal'] = 'SIDEWAYS'
                        final_signal_row['timestamp'] = sideways_result['timestamp'].iloc[0]
                        analysis_results['Sideways_Analysis'] = sideways_result
                # Proceed to save the signal
                await self.save_signal(symbol, timeframe, final_signal_row, analysis_results)
            else:
                logger.debug(f"No final signal determined for {symbol} {timeframe}.")
        else:
            logger.debug(f"No data available for {symbol} {timeframe} during signal generation.")

    def generate_signals(self, symbol, timeframe, df, analysis_set='first'):
        if df.empty:
            logger.debug(f"No data present for signal generation for {symbol} {timeframe}.")
            return None

        try:
            # Ensure the DataFrame is sorted by timestamp
            df = df.sort_values(by='timestamp')
            logger.debug(f"DataFrame for {symbol} {timeframe} has {len(df)} rows, latest timestamp: {df['timestamp'].iloc[-1]}")

            # Use the last N rows for indicator calculations
            recent_df = df.tail(250).copy()

            # Check if there's enough data to compute indicators
            if len(recent_df) < 150:
                logger.debug(f"Not enough data to perform technical analysis for {symbol} {timeframe}.")
                return None

            # Calculate indicators based on analysis set
            if analysis_set == 'first':
                # First set of indicators
                # Calculate EMA and SMA
                recent_df['EMA25'] = ta.EMA(recent_df['close'], timeperiod=25)
                recent_df['SMA25'] = ta.SMA(recent_df['close'], timeperiod=25)

                # Calculate RSI
                recent_df['RSI'] = ta.RSI(recent_df['close'], timeperiod=14)

                # Calculate Bollinger Bands
                recent_df['upper_band'], recent_df['middle_band'], recent_df['lower_band'] = ta.BBANDS(
                    recent_df['close'], timeperiod=20, nbdevup=2, nbdevdn=2
                )

            elif analysis_set == 'second':
                # Second set of indicators
                # Calculate MACD
                recent_df['macd'], recent_df['macd_signal'], recent_df['macd_hist'] = ta.MACD(
                    recent_df['close'], fastperiod=12, slowperiod=26, signalperiod=9
                )

                # Calculate Stochastic Oscillator
                recent_df['slowk'], recent_df['slowd'] = ta.STOCH(
                    recent_df['high'], recent_df['low'], recent_df['close'],
                    fastk_period=14, slowk_period=3, slowk_matype=0,
                    slowd_period=3, slowd_matype=0
                )

                # Calculate ATR for volatility
                recent_df['ATR'] = ta.ATR(recent_df['high'], recent_df['low'], recent_df['close'], timeperiod=14)

            else:
                logger.error(f"Invalid analysis set specified: {analysis_set}")
                return None

            # Drop NaN values resulting from calculations
            recent_df.dropna(inplace=True)

            if recent_df.empty:
                logger.debug(f"After indicator calculations, data is empty for {symbol} {timeframe}.")
                return None

            # Focus on the last data point for signal generation
            last_row = recent_df.iloc[-1]
            previous_row = recent_df.iloc[-2]  # For comparing previous indicator values

            # Signal generation logic based on analysis set
            if analysis_set == 'first':
                # Generate signals based on first set of indicators
                bullish_condition = (
                    (last_row['EMA25'] > last_row['SMA25']) and
                    (previous_row['EMA25'] <= previous_row['SMA25']) and
                    (last_row['RSI'] > 50)
                )

                bearish_condition = (
                    (last_row['EMA25'] < last_row['SMA25']) and
                    (previous_row['EMA25'] >= previous_row['SMA25']) and
                    (last_row['RSI'] < 50)
                )
            elif analysis_set == 'second':
                # Generate signals based on second set of indicators
                bullish_condition = (
                    (last_row['macd'] > last_row['macd_signal']) and
                    (previous_row['macd'] <= previous_row['macd_signal']) and
                    (last_row['slowk'] < 80 and last_row['slowd'] < 80)  # Not overbought
                )

                bearish_condition = (
                    (last_row['macd'] < last_row['macd_signal']) and
                    (previous_row['macd'] >= previous_row['macd_signal']) and
                    (last_row['slowk'] > 20 and last_row['slowd'] > 20)  # Not oversold
                )

            # Determine the signal
            if bullish_condition:
                signal = 'BUY'
            elif bearish_condition:
                signal = 'SELL'
            else:
                signal = 'NO_SIGNAL'

            # Assign the signal to the last row
            last_row = last_row.copy()
            last_row['Signal'] = signal

            return last_row.to_frame().T

        except Exception as e:
            logger.error(f"Error generating signals for {symbol} {timeframe}: {e}\n{traceback.format_exc()}")
            return None

    def price_action_analysis(self, df):
        try:
            # Check for higher highs and higher lows (uptrend) or lower highs and lower lows (downtrend)
            recent_df = df.tail(50).copy()
            recent_df.dropna(inplace=True)

            if len(recent_df) < 10:
                return None

            highs = recent_df['high']
            lows = recent_df['low']

            # Simple logic to detect uptrend or downtrend
            uptrend = all(x <= y for x, y in zip(lows, lows[1:])) and all(x <= y for x, y in zip(highs, highs[1:]))
            downtrend = all(x >= y for x, y in zip(highs, highs[1:])) and all(x >= y for x, y in zip(lows, lows[1:]))

            if uptrend:
                signal = 'BUY'
            elif downtrend:
                signal = 'SELL'
            else:
                signal = 'NO_SIGNAL'

            last_row = recent_df.iloc[-1].copy()
            last_row['Signal'] = signal

            return last_row.to_frame().T

        except Exception as e:
            logger.error(f"Error in price action analysis: {e}\n{traceback.format_exc()}")
            return None

    def chart_patterns_analysis(self, df):
        try:
            recent_df = df.tail(100).copy()
            recent_df.dropna(inplace=True)

            if len(recent_df) < 20:
                return None

            # Candlestick pattern recognition
            pattern_functions = {
                'hammer': ta.CDLHAMMER,
                'engulfing': ta.CDLENGULFING,
                'morning_star': ta.CDLMORNINGSTAR,
                'evening_star': ta.CDLEVENINGSTAR,
                'three_white_soldiers': ta.CDL3WHITESOLDIERS,
                'three_black_crows': ta.CDL3BLACKCROWS,
                'doji': ta.CDLDOJI,
                'shooting_star': ta.CDLSHOOTINGSTAR,
                'hanging_man': ta.CDLHANGINGMAN,
                'piercing': ta.CDLPIERCING,
                # Add more patterns as needed
            }

            signals = []
            detected_patterns = []
            for pattern_name, pattern_function in pattern_functions.items():
                results = pattern_function(recent_df['open'], recent_df['high'], recent_df['low'], recent_df['close'])
                last_result = results.iloc[-1]
                if last_result > 0:
                    signals.append('BUY')
                    detected_patterns.append(pattern_name)
                elif last_result < 0:
                    signals.append('SELL')
                    detected_patterns.append(pattern_name)

            # Determine the signal based on detected patterns
            if 'BUY' in signals:
                signal = 'BUY'
            elif 'SELL' in signals:
                signal = 'SELL'
            else:
                signal = 'NO_SIGNAL'

            last_row = recent_df.iloc[-1].copy()
            last_row['Signal'] = signal
            last_row['Detected_Patterns'] = ', '.join(detected_patterns)

            return last_row.to_frame().T

        except Exception as e:
            logger.error(f"Error in chart patterns analysis: {e}\n{traceback.format_exc()}")
            return None

    def volume_analysis(self, df):
        try:
            recent_df = df.tail(100).copy()
            recent_df.dropna(inplace=True)

            if len(recent_df) < 20:
                return None

            # Calculate average volume
            recent_df['AvgVolume'] = recent_df['volume'].rolling(window=20).mean()

            last_row = recent_df.iloc[-1]
            previous_row = recent_df.iloc[-2]

            # Detect volume spike indicating potential momentum
            volume_spike = last_row['volume'] > (1.5 * last_row['AvgVolume'])

            if volume_spike and last_row['close'] > previous_row['close']:
                signal = 'BUY'
            elif volume_spike and last_row['close'] < previous_row['close']:
                signal = 'SELL'
            else:
                signal = 'NO_SIGNAL'

            last_row = last_row.copy()
            last_row['Signal'] = signal

            return last_row.to_frame().T

        except Exception as e:
            logger.error(f"Error in volume analysis: {e}\n{traceback.format_exc()}")
            return None

    def machine_learning_analysis(self, df):
        try:
            from sklearn.preprocessing import StandardScaler
            from sklearn.model_selection import train_test_split
            from sklearn.ensemble import RandomForestClassifier
            from sklearn.svm import SVC
            from sklearn.neural_network import MLPClassifier

            recent_df = df.tail(200).copy()
            recent_df.dropna(inplace=True)

            if len(recent_df) < 50:
                return None

            # Prepare features and target
            recent_df['Returns'] = recent_df['close'].pct_change()
            recent_df['Direction'] = np.where(recent_df['Returns'] > 0, 1, 0)
            recent_df['Lag1'] = recent_df['Returns'].shift(1)
            recent_df['Lag2'] = recent_df['Returns'].shift(2)
            recent_df.dropna(inplace=True)

            X = recent_df[['Lag1', 'Lag2']]
            y = recent_df['Direction']

            scaler = StandardScaler()
            X_scaled = scaler.fit_transform(X)

            # Split into training and test sets
            X_train, X_test, y_train, y_test = train_test_split(X_scaled[:-1], y[:-1], test_size=0.2, random_state=42)

            # Model 1: Random Forest
            rf_model = RandomForestClassifier(n_estimators=100, random_state=42)
            rf_model.fit(X_train, y_train)
            rf_prediction = rf_model.predict(X_scaled[-1].reshape(1, -1))[0]

            # Model 2: Support Vector Machine
            svm_model = SVC(probability=True)
            svm_model.fit(X_train, y_train)
            svm_prediction = svm_model.predict(X_scaled[-1].reshape(1, -1))[0]

            # Model 3: Neural Network
            nn_model = MLPClassifier(hidden_layer_sizes=(50, ), max_iter=1000, random_state=42)
            nn_model.fit(X_train, y_train)
            nn_prediction = nn_model.predict(X_scaled[-1].reshape(1, -1))[0]

            # Aggregate predictions
            predictions = [rf_prediction, svm_prediction, nn_prediction]
            prediction_sum = sum(predictions)
            if prediction_sum >= 2:
                signal = 'BUY'
            elif prediction_sum <= 1:
                signal = 'SELL'
            else:
                signal = 'NO_SIGNAL'

            last_row = recent_df.iloc[-1].copy()
            last_row['Signal'] = signal

            return last_row.to_frame().T

        except Exception as e:
            logger.error(f"Error in machine learning analysis: {e}\n{traceback.format_exc()}")
            return None

    def generate_sideways_signal(self, symbol, timeframe, df):
        try:
            recent_df = df.copy()
            recent_df.dropna(inplace=True)

            if len(recent_df) < 100:
                return None

            # Calculate indicators
            # 1. ADX
            recent_df['ADX'] = ta.ADX(recent_df['high'], recent_df['low'], recent_df['close'], timeperiod=14)

            # 2. PPO
            recent_df['PPO'] = ta.PPO(recent_df['close'], fastperiod=12, slowperiod=26, matype=0)

            # 3. Bollinger Bands
            recent_df['upper_band'], recent_df['middle_band'], recent_df['lower_band'] = ta.BBANDS(
                recent_df['close'], timeperiod=20, nbdevup=2, nbdevdn=2)

            # 4. RSI
            recent_df['RSI'] = ta.RSI(recent_df['close'], timeperiod=14)

            # 5. MFI (Money Flow Index)
            recent_df['MFI'] = ta.MFI(recent_df['high'], recent_df['low'], recent_df['close'], recent_df['volume'], timeperiod=14)

            # Conditions to identify sideways market
            # We consider that a sideways market exists if any three of the following are true:

            # Condition list
            conditions = []

            # Condition 1: ADX is below threshold indicating weak trend
            adx_threshold = 25
            conditions.append(recent_df['ADX'].iloc[-1] < adx_threshold)

            # Condition 2: PPO is near zero indicating no momentum
            ppo_threshold = 0.5  # Adjust as needed
            conditions.append(abs(recent_df['PPO'].iloc[-1]) < ppo_threshold)

            # Condition 3: Bollinger Bands are constricting (bandwidth is narrow)
            recent_df['bandwidth'] = (recent_df['upper_band'] - recent_df['lower_band']) / recent_df['middle_band']
            bandwidth_threshold = 0.05  # Adjust as needed
            conditions.append(recent_df['bandwidth'].iloc[-1] < bandwidth_threshold)

            # Condition 4: RSI is between 45 and 55 indicating neutral momentum
            conditions.append(40 <= recent_df['RSI'].iloc[-1] <= 55)

            # Condition 5: MFI is between 45 and 55 indicating balanced money flow
            conditions.append(40 <= recent_df['MFI'].iloc[-1] <= 55)

            # Count the number of true conditions
            true_conditions = sum(conditions)

            if true_conditions >= 2:
                signal = 'SIDEWAYS'
            else:
                signal = 'NO_SIGNAL'

            last_row = recent_df.iloc[-1].copy()
            last_row['Signal'] = signal

            # Store the indicator values in last_row for display purpose
            last_row['ADX_Value'] = recent_df['ADX'].iloc[-1]
            last_row['PPO_Value'] = recent_df['PPO'].iloc[-1]
            last_row['Bandwidth_Value'] = recent_df['bandwidth'].iloc[-1]
            last_row['RSI_Value'] = recent_df['RSI'].iloc[-1]
            last_row['MFI_Value'] = recent_df['MFI'].iloc[-1]

            return last_row.to_frame().T

        except Exception as e:
            logger.error(f"Error generating sideways signal for {symbol} {timeframe}: {e}\n{traceback.format_exc()}")
            return None

    def combine_analysis_results(self, analysis_results):
        if not analysis_results:
            return None

        signals = []
        for analysis, result in analysis_results.items():
            if result is not None and 'Signal' in result.columns:
                signals.append(result['Signal'].iloc[0])
            else:
                signals.append('NO_SIGNAL')

        # Count the number of BUY and SELL signals
        buy_signals = signals.count('BUY')
        sell_signals = signals.count('SELL')

        # Determine the overall signal based on the number of analyses agreeing
        if buy_signals >= 5:
            final_signal = 'VERY_BULLISH'
        elif buy_signals >= 3:
            final_signal = 'BULLISH'
        elif buy_signals >= 1:
            final_signal = 'LESS_BULLISH'
        elif sell_signals >= 5:
            final_signal = 'VERY_BEARISH'
        elif sell_signals >= 3:
            final_signal = 'BEARISH'
        elif sell_signals >= 1:
            final_signal = 'LESS_BEARISH'
        else:
            final_signal = 'NO_SIGNAL'  # No bullish or bearish signals

        # Get the latest timestamp from analysis results for saving
        timestamps = [result['timestamp'].iloc[0] for result in analysis_results.values() if result is not None and 'timestamp' in result.columns]
        if not timestamps:
            return None
        latest_timestamp = max(timestamps)

        # Create a final signal row to return
        final_signal_row = pd.DataFrame({
            'timestamp': [latest_timestamp],
            'Final_Signal': [final_signal],
            'Signals': [signals]
        })

        return final_signal_row

    def find_support_resistance(self, df):
        try:
            recent_df = df.tail(100)
            recent_highs = recent_df['high']
            recent_lows = recent_df['low']

            # Find support level: recent lows
            support_level = recent_lows.min()
            # Find resistance level: recent highs
            resistance_level = recent_highs.max()

            return support_level, resistance_level

        except Exception as e:
            logger.error(f"Error finding support and resistance levels: {e}\n{traceback.format_exc()}")
            return None, None
  
    async def save_signal(self, symbol, timeframe, final_signal_row, analysis_results):
        try:
            timestamp = final_signal_row['timestamp'].iloc[0]
            if isinstance(timestamp, pd.Timestamp):
                timestamp = timestamp.to_pydatetime()

            run_date = timezone.now()
            final_signal = final_signal_row['Final_Signal'].iloc[0]

        # Fetch price and other details
            close_price = self.dfs[symbol][timeframe]['close'].iloc[-1]
        # Determine support and resistance levels
            support_level, resistance_level = self.find_support_resistance(self.dfs[symbol][timeframe])

        # Calculate stop loss, take profit, and risk-reward ratio
            if final_signal in ['VERY_BULLISH', 'BULLISH', 'LESS_BULLISH']:
                stop_loss = support_level
                take_profit = resistance_level
                risk_reward_ratio = abs((take_profit - close_price) / (close_price - stop_loss))
            elif final_signal in ['VERY_BEARISH', 'BEARISH', 'LESS_BEARISH']:
                stop_loss = resistance_level
                take_profit = support_level
                risk_reward_ratio = abs((close_price - take_profit) / (stop_loss - close_price))
            elif final_signal == 'SIDEWAYS':
                stop_loss = None
                take_profit = None
                risk_reward_ratio = None
            else:
                stop_loss = None
                take_profit = None
                risk_reward_ratio = None

        # Fetch 24h price change for the symbol
            try:
                ticker = await self.exchange.fetch_ticker(symbol)
                price_change_24h = ticker.get('percentage', 0.0)
                if price_change_24h is None:
                    price_change_24h = 0.0
            except Exception as e:
                logger.error(f"Error fetching ticker for {symbol}: {e}")
                price_change_24h = 0.0

        # Determine market regime
            if final_signal == 'SIDEWAYS':
                market_regime = 'SIDEWAYS'
            else:
                market_regime = 'TRENDING'

        # Fetch other timeframe signals if applicable
            other_timeframe_signals = {}
            for tf in self.timeframes:
                if tf != timeframe:
                    async with self.data_lock:
                        if symbol in self.dfs and tf in self.dfs[symbol]:
                            df = self.dfs[symbol][tf]
                            if not df.empty:
                               # Perform analysis for other timeframe
                                analysis_results_other_tf = {}
                                df_copy = df.copy()
                                analysis1_result = self.generate_signals(symbol, tf, df_copy, analysis_set='first')
                                analysis2_result = self.generate_signals(symbol, tf, df_copy, analysis_set='second')
                                price_action_result = self.price_action_analysis(df_copy)
                                analysis_results_other_tf['Analysis1'] = analysis1_result
                                analysis_results_other_tf['Analysis2'] = analysis2_result
                                analysis_results_other_tf['Price_Action'] = price_action_result

                                combined_signal_row = self.combine_analysis_results(analysis_results_other_tf)
                                if combined_signal_row is not None:
                                    other_timeframe_signals[tf] = combined_signal_row['Final_Signal'].iloc[0]
                                else:
                                    other_timeframe_signals[tf] = 'NO_SIGNAL'

        # Compile additional information
            additional_info = {
                'support': support_level,
                'resistance': resistance_level,
                'price': close_price,
                'price_change_24h': price_change_24h,
                'stop_loss': stop_loss,
                'take_profit': take_profit,
                'risk_reward_ratio': risk_reward_ratio,
                'market_regime': market_regime,
                'other_timeframe_signals': other_timeframe_signals,
                'analysis_results': {},
            }

            # Include results of all indicators in 'analysis_results'
            for analysis_name, result in analysis_results.items():
                if result is not None:
                # Convert result to dict, including indicator values
                    analysis_info = result.iloc[-1].to_dict()
                # Convert any Timestamps to strings
                    for key, value in analysis_info.items():
                        if isinstance(value, pd.Timestamp):
                            analysis_info[key] = value.isoformat()
                    additional_info['analysis_results'][analysis_name] = analysis_info

        # Ensure that values in other_timeframe_signals are JSON serializable
            for key, value in other_timeframe_signals.items():
                if isinstance(value, pd.Timestamp):
                    other_timeframe_signals[key] = value.isoformat()

            # Save or update the Signal object in the database
            await database_sync_to_async(Signal.objects.update_or_create)(
                timestamp=timestamp,
                symbol=symbol,
                timeframe=timeframe,
                defaults={
                    'signal_type': final_signal,
                    'run_date': run_date,
                    'price': close_price,
                    'price_change_24h': price_change_24h,
                    'stop_loss': stop_loss,
                    'take_profit': take_profit,
                    'risk_reward_ratio': risk_reward_ratio,
                    'market_regime': market_regime,
                    'additional_info': additional_info,
                }
            )
            logger.info(f"Signal saved for {symbol} {timeframe} at {timestamp}: {final_signal}")

        except Exception as e:
            logger.error(f"Error saving signal for {symbol} {timeframe}: {e}\n{traceback.format_exc()}")

    async def run(self):
        try:
            await self.initialize_exchange()
            await self.fetch_historical_data()
            await self.fetch_realtime_data()
        finally:
            await self.exchange.close()

async def main():
    exchange = ccxt.binanceusdm({'enableRateLimit': True})
    await exchange.load_markets()
    markets = exchange.markets

    # Filter for USDT perpetual futures pairs
    try:
        usdt_pairs = []
        for symbol, market in markets.items():
            # Ensure the market is active and is a perpetual swap contract
            if market.get('active'):
                if market.get('contract') is True:
                    if market.get('type') == 'swap':
                        if market.get('linear') is True:
                            if market['quote'] == 'USDT':
                                usdt_pairs.append(symbol)
                                logger.debug(f"Added symbol: {symbol}")
        # Remove duplicates just in case
        usdt_pairs = list(set(usdt_pairs))
        logger.info(f"Total Futures USDT Pairs: {len(usdt_pairs)}")

        if not usdt_pairs:
            logger.warning("No USDT pairs found after filtering. Please check the filtering criteria.")
            return  # Exit the function if no symbols found

        # Fetch 24h price change data
        tickers = await exchange.fetch_tickers()
        logger.info(f"Fetched tickers for {len(tickers)} symbols.")

        # Create a list of symbols with their 24h price change percentage
        symbol_changes = []
        for symbol in usdt_pairs:
            ticker = tickers.get(symbol)
            if ticker:
                change_percentage = ticker.get('percentage', 0)
                symbol_changes.append((symbol, abs(change_percentage)))
            else:
                logger.warning(f"No ticker data found for symbol {symbol}")

        if not symbol_changes:
            logger.error("No symbols with ticker data found. Exiting.")
            return

        # Sort symbols by absolute 24h price change percentage in descending order
        symbol_changes.sort(key=lambda x: x[1], reverse=True)

        # Extract sorted symbols
        sorted_symbols = [symbol for symbol, change in symbol_changes]

        symbols = sorted_symbols
        num_groups = 6  # Limit the number of concurrent WebSocket connections
        grouped_symbols = [symbols[i::num_groups] for i in range(num_groups)]

        tasks = []
        for group_symbols in grouped_symbols:
            if not group_symbols:
                continue  # Skip empty groups
            logger.info(f"Processing symbols group: {group_symbols}")
            signal_generator = CombinedSignalGenerator(symbols=group_symbols)
            tasks.append(asyncio.create_task(signal_generator.run()))

        if tasks:
            await asyncio.gather(*tasks)
        else:
            logger.error("No tasks to run. Exiting.")

    except Exception as e:
        logger.error(f"An error occurred: {e}\n{traceback.format_exc()}")

    finally:
        # Ensure we close the exchange client
        await exchange.close()
        logger.info("Exchange client closed.")

if __name__ == "__main__":
    if sys.platform.startswith('win'):
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
    asyncio.run(main())

Can you analyze this code to identify the strengths and weaknesses in it. Please make changes to correct the weaknesses and send me complete code. Also, I have identified a few weaknesses and put my recommendations below, please also make the changes I have put below, and prioritize pretrained models and you can put a maximum of 5 machine learning models and aggregate their predictions to produce one final prediction. Write the whole code with the changes and send to me.
1. Performance and Efficiency (Improving Memory Management and Complexity Reduction):

Switch unnecessary math operations to functions that utilize native C (like numpy's) instead of Python's slower built-ins. This can provide a significant performance gain because the math operations that numpy optimizes for are performed in C instead of Python, leading to faster execution.
Pre-allocate arrays when possible. In many parts of the code, data from the exchange is continuously appended to DataFrames. Pre-allocating these arrays (setting them to a predefined size before appending) might be more efficient than continuously increasing the size of these arrays.
Avoid creating unnecessary copies of large data sets (e.g., using df.copy()). Instead, when passing DataFrames or subsets of DataFrames to other methods or functions, pass them by reference with df and df[:]. Try to use df.loc or df.iloc functions instead of df.copy() as much as possible.
Optimize for speed by using more efficient code in methods with high computational load. While readability is important, a little additional complexity in the code can have a significant impact on performance. For example, certain methods in ta (Technical Analysis library) might have slower counterparts that can be replaced for significant performance gains.
Use data compression. Where possible, try compressing stored historical data, which can help reduce memory usage and allow for quicker loading times in subsequent runs of the bot.
The goal of these optimizations is to reduce the time and resources spent on data processing and fetching, leading to a more efficient bot that can handle larger data sets without overwhelming the system resources.

2. Machine Learning Integration:

The current bot uses a mix of Random Forest, SVM, and MLPClassifier to predict the next price action based on historic and real-time data. While these models are common and effective for many use cases, there are more advanced methods and techniques that you could potentially explore to improve the performance of your bot:

Long Short-Term Memory (LSTM) networks: LSTM is an advanced type of Recurrent Neural Network (RNN) that can learn long-term dependencies. This can be effective for time-series prediction, especially in the case of financial data where trends might last for days, weeks, or even months.
Generative Adative Adversarial Networks (GANs): While not a common choice in financial time series prediction, GANs could potentially be used to create synthetic data, which would increase available data for training, potentially leading to more accurate models.
Reinforcement learning: These methods involve an "agent" that learns to take actions in an environment (in this case, the exchange) in order to maximize a reward. This could be used to automatically adjust trading strategies based on market conditions.
However, implementing more complex models would first require a deep understanding of these techniques and their applicability to your specific use case. Ensure to do sufficient research and testing before making any changes to the bot's machine learning model. The benefits of these more advanced models could include increased prediction accuracy and better handling of non-linear trends and patterns in the market data.