i printed current_leg_change        0.0
current_leg_change   -2.06971
Name: 201, dtype: object         print( df.at[i + seq_length, 'current_leg_change'] )
        print(df)
        if pd.isna( df.at[i + seq_length, 'current_leg_change'] ):
        	leg_dir = 0  # Handle NaN
        else:
        	if df.at[i + seq_length, 'current_leg_change'] > 0:
        	 leg_dir = 1
        	else:
        	 leg_dir = 0
e "/home/elliot/Downloads/quant work/transformers/script7.py", line 508, in prepare_sequences
    if pd.isna( df.at[i + seq_length, 'current_leg_change'] ):
  File "/home/elliot/Downloads/quant work/transformers/trans/lib/python3.11/site-packages/pandas/core/generic.py", line 1577, in __nonzero__
    raise ValueError(
ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().
 code : import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import LabelEncoder, StandardScaler
from sklearn.model_selection import train_test_split
from tensorflow.keras.layers import Input, LayerNormalization, MultiHeadAttention, Dropout, Dense, GlobalAveragePooling1D
from tensorflow.keras.models import Model
import tensorflow as tf
from ta.volatility import AverageTrueRange, BollingerBands
from ta.momentum import RSIIndicator
from ta.trend import MACD, ADXIndicator
from binance.client import Client  # Ensure you have the Binance API installed: pip install python-binance
import json
import datetime
import pickle
import threading
import time

# ----------------------------
# Configuration and Global Variables
# ----------------------------

# Binance API credentials (replace with your actual API key and secret)
BINANCE_API_KEY = 'YOUR_API_KEY'
BINANCE_API_SECRET = 'YOUR_API_SECRET'

client = Client(BINANCE_API_KEY, BINANCE_API_SECRET)

# Data collection settings
SYMBOLS = ['BTCUSDT']  # Add more symbols as needed
INTERVAL = '1s'  # Binance supports intervals like '1m', '5m', etc.
SEQ_LENGTH = 201
TARGET_STEPS = 1
LIMIT = 2000  # Increased limit to ensure sufficient data for rolling calculations

# Initialize data_frames dictionary to store DataFrames for each symbol
data_frames = {symbol: pd.DataFrame(columns=[
    'open_time', 'open', 'high', 'low', 'close', 'volume',
    'RSI', 'MACD', 'MACD_signal', 'MACD_diff', 'ATR',
    'BB_upper', 'BB_lower', 'BB_width', 'ADX',
    'daily_return', 'RV',
    'MA_short', 'MA_long', 'MA_Trend',
    'RSI_Trend', 'MACD_Trend', 'ADX_Strength',
    'Market_Trend', 'ATR_Vol', 'BB_Vol', 'RV_Vol',
    'Volatility', 'Market_Environment',
    'previous_leg_change', 'previous_leg_length',
    'current_leg_change', 'current_leg_length',
    'percent_change', 'normalized_close_price',
    'percent_change_classification',
    'close_price',  # Target
    'leg_direction',  # Target
    'leg_length'  # Target
]) for symbol in SYMBOLS}

# Lock for thread-safe operations on data_frames
data_lock = threading.Lock()

# ----------------------------
# Data Loading Functions
# ----------------------------

def pre_load_data(symbols, interval=INTERVAL, limit=LIMIT):
    """
    Fetches historical kline data for the given symbols and populates data_frames.

    :param symbols: List of symbols to fetch data for.
    :param interval: Kline interval (e.g., '1m', '5m').
    :param limit: Number of historical data points to fetch.
    :return: None
    """
    for symbol in symbols:
        try:
            historicals = client.get_klines(symbol=symbol, interval=interval, limit=limit)
            for kl in historicals:
                open_time = datetime.datetime.fromtimestamp(kl[0] / 1000)
                o = float(kl[1])
                h = float(kl[2])
                l = float(kl[3])
                c = float(kl[4])
                v = float(kl[5])

                new_row = {
                    'open_time': open_time,
                    'open': o,
                    'high': h,
                    'low': l,
                    'close': c,
                    'volume': v
                }

                # Replace append with pd.concat
                new_df = pd.DataFrame([new_row])
                with data_lock:
                    data_frames[symbol] = pd.concat([data_frames[symbol], new_df], ignore_index=True)
            print(f"Pre-loaded {len(historicals)} data points for {symbol}.")
        except Exception as e:
            print(f"Error fetching historical data for {symbol}: {e}")

# ----------------------------
# Data Processing and Feature Engineering
# ----------------------------

def calculate_technical_indicators(df):
    """
    Calculate technical indicators and add them to the DataFrame.
    """
    if len(df) < 14:
        # Not enough data to calculate indicators
        return df

    # Calculate RSI
    rsi = RSIIndicator(close=df['close'], window=14)
    df['RSI'] = rsi.rsi()

    # Calculate MACD
    macd = MACD(close=df['close'], window_slow=26, window_fast=12, window_sign=9)
    df['MACD'] = macd.macd()
    df['MACD_signal'] = macd.macd_signal()
    df['MACD_diff'] = macd.macd_diff()

    # Calculate ATR
    atr = AverageTrueRange(high=df['high'], low=df['low'], close=df['close'], window=14)
    df['ATR'] = atr.average_true_range()

    # Calculate Bollinger Bands Width
    bollinger = BollingerBands(close=df['close'], window=20, window_dev=2)
    df['BB_upper'] = bollinger.bollinger_hband()
    df['BB_lower'] = bollinger.bollinger_lband()
    df['BB_width'] = (df['BB_upper'] - df['BB_lower']) / df['close']

    # Calculate ADX
    adx = ADXIndicator(high=df['high'], low=df['low'], close=df['close'], window=14)
    df['ADX'] = adx.adx()

    # Calculate Realized Volatility (Rolling 20-window standard deviation of returns)
    df['daily_return'] = df['close'].pct_change()
    df['RV'] = df['daily_return'].rolling(window=20).std() * np.sqrt(252)  # Annualized

    return df

def classify_trend(df):
    """
    Classify market trend as Bullish, Neutral, or Bearish based on moving averages, RSI, MACD, and ADX.
    """
    # Calculate Moving Averages
    df['MA_short'] = df['close'].rolling(window=50).mean()
    df['MA_long'] = df['close'].rolling(window=200).mean()

    # Trend based on Moving Averages
    def ma_trend(row):
        if pd.isna(row['MA_short']) or pd.isna(row['MA_long']):
            return 'Neutral'
        if row['MA_short'] > row['MA_long']:
            return 'Bullish'
        elif row['MA_short'] < row['MA_long']:
            return 'Bearish'
        else:
            return 'Neutral'

    df['MA_Trend'] = df.apply(ma_trend, axis=1)

    # Trend based on RSI
    def rsi_trend(row):
        if pd.isna(row['RSI']):
            return 'Neutral'
        if row['RSI'] > 60:
            return 'Bullish'
        elif row['RSI'] < 40:
            return 'Bearish'
        else:
            return 'Neutral'

    df['RSI_Trend'] = df.apply(rsi_trend, axis=1)

    # Trend based on MACD
    def macd_trend(row):
        if pd.isna(row['MACD']) or pd.isna(row['MACD_signal']):
            return 'Neutral'
        if row['MACD'] > row['MACD_signal'] and row['MACD'] > 0:
            return 'Bullish'
        elif row['MACD'] < row['MACD_signal'] and row['MACD'] < 0:
            return 'Bearish'
        else:
            return 'Neutral'

    df['MACD_Trend'] = df.apply(macd_trend, axis=1)

    # Trend Strength based on ADX
    def adx_strength(row):
        if pd.isna(row['ADX']):
            return 'Weak'
        if row['ADX'] > 25:
            return 'Strong'
        else:
            return 'Weak'

    df['ADX_Strength'] = df.apply(adx_strength, axis=1)

    # Combine Trends
    def combined_trend(row):
        trends = [row['MA_Trend'], row['RSI_Trend'], row['MACD_Trend']]
        bullish = trends.count('Bullish')
        bearish = trends.count('Bearish')
        neutral = trends.count('Neutral')

        if bullish > bearish and bullish > neutral:
            base_trend = 'Bullish'
        elif bearish > bullish and bearish > neutral:
            base_trend = 'Bearish'
        else:
            base_trend = 'Neutral'

        if row['ADX_Strength'] == 'Strong':
            return base_trend
        else:
            return 'Neutral'  # Treat weak trends as neutral

    df['Market_Trend'] = df.apply(combined_trend, axis=1)

    return df

def classify_volatility(df):
    """
    Classify volatility as High, Medium, or Low based on ATR, Bollinger Bands Width, and Realized Volatility.
    """
    if len(df) < 200:
        df['Volatility'] = 'Medium'
        return df

    # ATR Volatility Classification
    atr_mavg = df['ATR'].rolling(window=14).mean().shift(1)  # Shift to avoid look-ahead bias
    df['ATR_Vol'] = df.apply(
        lambda row: 'High' if row['ATR'] > 1.2 * atr_mavg.get(row.name, np.nan)
                    else ('Medium' if 0.9 * atr_mavg.get(row.name, np.nan) <= row['ATR'] <= 1.1 * atr_mavg.get(row.name, np.nan)
                          else 'Low'),
        axis=1
    )

    # Bollinger Bands Width Classification
    bb_mavg = df['BB_width'].rolling(window=20).mean().shift(1)
    df['BB_Vol'] = df.apply(
        lambda row: 'High' if row['BB_width'] > 1.2 * bb_mavg.get(row.name, np.nan)
                    else ('Medium' if 0.9 * bb_mavg.get(row.name, np.nan) <= row['BB_width'] <= 1.1 * bb_mavg.get(row.name, np.nan)
                          else 'Low'),
        axis=1
    )

    # Realized Volatility Classification
    rv_80 = df['RV'].quantile(0.8)
    rv_20 = df['RV'].quantile(0.2)
    df['RV_Vol'] = df['RV'].apply(
        lambda x: 'High' if x > rv_80
                  else ('Medium' if rv_20 <= x <= rv_80
                        else 'Low')
    )

    # Voting Mechanism for Volatility
    def volatility_vote(row):
        votes = [row['ATR_Vol'], row['BB_Vol'], row['RV_Vol']]
        high = votes.count('High')
        medium = votes.count('Medium')
        low = votes.count('Low')
        if high > medium and high > low:
            return 'High'
        elif medium > high and medium > low:
            return 'Medium'
        else:
            return 'Low'

    df['Volatility'] = df.apply(volatility_vote, axis=1)

    return df

def define_market_environment(df):
    """
    Combine Volatility and Market Trend to define Market Environment.
    """
    df['Market_Environment'] = df.apply(lambda row: f"{row['Volatility']} Vol/{row['Market_Trend']}", axis=1)
    return df

def calculate_leg_data(df):
    """
    Track the magnitude and duration of each leg (price movement direction).
    """
    previous_leg_change = 0
    previous_leg_length = 0
    current_leg_change = 0
    current_leg_length = 0

    legs = []

    for index, row in df.iterrows():
        if index == 0:
            # Initialize
            legs.append({
                'previous_leg_change': previous_leg_change,
                'previous_leg_length': previous_leg_length,
                'current_leg_change': current_leg_change,
                'current_leg_length': current_leg_length
            })
            continue

        percent_delta = (row['close'] - df.at[index-1, 'close']) / df.at[index-1, 'close']

        if current_leg_length == 0:
            current_leg_change = percent_delta
            current_leg_length = 1
        else:
            if (current_leg_change > 0 and percent_delta > 0) or (current_leg_change < 0 and percent_delta < 0):
                # Continue current leg
                current_leg_change += percent_delta
                current_leg_length += 1
            else:
                # Direction changed
                previous_leg_change = current_leg_change
                previous_leg_length = current_leg_length
                current_leg_change = percent_delta
                current_leg_length = 1

        legs.append({
            'previous_leg_change': previous_leg_change,
            'previous_leg_length': previous_leg_length,
            'current_leg_change': current_leg_change,
            'current_leg_length': current_leg_length
        })

    legs_df = pd.DataFrame(legs)
    df = pd.concat([df, legs_df], axis=1)
    return df

def classify_percent_change(df):
    """
    Discretize continuous percent changes into 7 classification buckets.
    """
    # Calculate percent changes
    df['percent_change'] = df['close'].pct_change()

    # Normalize Close Price for Regression Target
    df['normalized_close_price'] = (df['close'] - df['close'].mean()) / df['close'].std()

    # Remove NaNs
    df = df.dropna(subset=['percent_change', 'normalized_close_price']).reset_index(drop=True)

    # Determine percentiles
    percentiles = df['percent_change'].quantile([0.05, 0.20, 0.40, 0.60, 0.80, 0.95]).to_dict()

    # Define classification function
    def classify(x, p):
        if x < p[0.05]:
            return 'Down a Lot'
        elif x < p[0.20]:
            return 'Down Moderate'
        elif x < p[0.40]:
            return 'Down a Little'
        elif x < p[0.60]:
            return 'No Change'
        elif x < p[0.80]:
            return 'Up a Little'
        elif x < p[0.95]:
            return 'Up Moderate'
        else:
            return 'Up a Lot'

    # Apply classification
    df['percent_change_classification'] = df['percent_change'].apply(lambda x: classify(x, percentiles))

    return df

def update_features(symbol):
    """
    Update technical indicators and classifications for a given symbol.
    """
    with data_lock:
        df = data_frames[symbol].copy()

    if len(df) < 200:
        # Not enough data to calculate indicators and classify
        return

    # Calculate technical indicators
    df = calculate_technical_indicators(df)

    # Classify trend
    df = classify_trend(df)

    # Classify volatility
    df = classify_volatility(df)

    # Define market environment
    df = define_market_environment(df)

    # Calculate leg data
    df = calculate_leg_data(df)

    # Classify percent change
    df = classify_percent_change(df)

    # Handle any remaining NaNs or infs
    df.replace([np.inf, -np.inf], np.nan, inplace=True)
    df.fillna(method='ffill', inplace=True)
    df.fillna(method='bfill', inplace=True)

    with data_lock:
        data_frames[symbol] = df

# ----------------------------
# Label Encoding and Scaling
# ----------------------------
def encode_and_scale(df, label_encoders=None, scalers=None):
    """
    Encodes categorical features and scales numerical features.

    Args:
        df (pd.DataFrame): DataFrame to encode and scale.
        label_encoders (dict): Existing label encoders; creates new ones if None.
        scalers (dict): Existing scalers; creates new ones if None.

    Returns:
        df (pd.DataFrame): Encoded and scaled DataFrame.
        label_encoders (dict): Updated label encoders.
        scalers (dict): Updated scalers.
    """
    if label_encoders is None:
        label_encoders = {}

    if scalers is None:
        scalers = {}

    # Handle Infinite and NaN Values
    df.replace([np.inf, -np.inf], np.nan, inplace=True)
    df.fillna(method='ffill', inplace=True)
    df.fillna(method='bfill', inplace=True)  # In case NaNs are at the start

    # Encode categorical features
    categorical_features = ['percent_change_classification', 'Market_Trend', 'Volatility']
    for col in categorical_features:
        if col not in label_encoders:
            le = LabelEncoder()
            df[col] = le.fit_transform(df[col])
            label_encoders[col] = le
        else:
            le = label_encoders[col]
            df[col] = le.transform(df[col])

    # Scale numerical features
    numerical_features = [
        'RSI', 'MACD', 'MACD_signal', 'ATR', 'BB_width', 'ADX', 
        'previous_leg_change', 'previous_leg_length',
        'current_leg_change', 'current_leg_length'
    ]
    for col in numerical_features:
        if col not in scalers:
            scaler = StandardScaler()
            # To handle potential NaNs after feature engineering
            df[col] = scaler.fit_transform(df[[col]].fillna(0))
            scalers[col] = scaler
        else:
            scaler = scalers[col]
            df[col] = scaler.transform(df[[col]].fillna(0))

    return df, label_encoders, scalers

# ----------------------------
# Sequence Preparation
# ----------------------------

# Define your features and targets
FEATURES = [
    'RSI', 'MACD', 'MACD_signal', 'ATR', 'BB_width', 'ADX', 
    'previous_leg_change', 'previous_leg_length',
    'current_leg_change', 'current_leg_length',
    'Volatility', 'Market_Environment'
]

TARGETS = [
    'close_price', 
    'percent_change_classification', 
    'leg_direction', 
    'leg_length'
]

def prepare_sequences(df, seq_length=SEQ_LENGTH, target_steps=TARGET_STEPS):
    """
    Prepare sequences of data for the Transformer model.

    Args:
        df (pd.DataFrame): DataFrame containing all features and targets.
        seq_length (int): Length of each input sequence.
        target_steps (int): Number of future steps to predict.

    Returns:
        X (np.ndarray): Array of input sequences.
        Y (dict): Dictionary of target arrays for each output.
    """
    sequences = []
    targets_close = []
    targets_class = []
    targets_leg_dir = []
    targets_leg_length = []

    for i in range(len(df) - seq_length - target_steps + 1):
        seq = df.iloc[i:i + seq_length][FEATURES].values
        target_close = df.iloc[i + seq_length]['normalized_close_price']
        target_class = df.iloc[i + seq_length]['percent_change_classification']
        
        # Access scalar value correctly
        print( df.at[i + seq_length, 'current_leg_change'] )
        print(df)
        if pd.isna( df.at[i + seq_length, 'current_leg_change'] ):
        	leg_dir = 0  # Handle NaN
        else:
        	if df.at[i + seq_length, 'current_leg_change'] > 0:
        	 leg_dir = 1
        	else:
        	 leg_dir = 0

        
        target_leg_dir = leg_dir
        target_leg_length = df.at[i + seq_length, 'current_leg_length']

        sequences.append(seq)
        targets_close.append(target_close)
        targets_class.append(target_class)
        targets_leg_dir.append(target_leg_dir)
        targets_leg_length.append(target_leg_length)

    X = np.array(sequences)
    Y = {
        'close_price': np.array(targets_close),
        'percent_change_classification': np.array(targets_class),
        'leg_direction': np.array(targets_leg_dir),
        'leg_length': np.array(targets_leg_length)
    }

    return X, Y

# ----------------------------
# Model Architecture: Multi-Output Transformer
# ----------------------------

def transformer_encoder(inputs, head_size, num_heads, ff_dim, dropout=0.1):
    """
    Transformer Encoder Block.
    """
    # Layer Normalization 1
    x = LayerNormalization(epsilon=1e-6)(inputs)
    
    # Multi-Head Attention
    x = MultiHeadAttention(key_dim=head_size, num_heads=num_heads, dropout=dropout)(x, x)
    
    # Dropout
    x = Dropout(dropout)(x)
    
    # Residual Connection
    res = x + inputs
    
    # Layer Normalization 2
    x = LayerNormalization(epsilon=1e-6)(res)
    
    # Feed-Forward Network
    x = Dense(ff_dim, activation="relu")(x)
    x = Dropout(dropout)(x)
    x = Dense(inputs.shape[-1])(x)
    
    # Second Residual Connection
    return x + res

def build_multi_output_transformer_model(
    input_shape, 
    num_classes_classification, 
    head_size=256, 
    num_heads=4, 
    ff_dim=128, 
    num_transformer_blocks=2, 
    dropout=0.1
):
    """
    Builds a Transformer model with multiple outputs.
    
    Parameters:
    - input_shape: Tuple, shape of the input data (sequence_length, num_features)
    - num_classes_classification: Int, number of classes for classification tasks
    - head_size: Int, dimensionality of each attention head
    - num_heads: Int, number of attention heads
    - ff_dim: Int, dimensionality of the feed-forward network
    - num_transformer_blocks: Int, number of Transformer encoder blocks
    - dropout: Float, dropout rate
    
    Returns:
    - Keras Model object
    """
    inputs = Input(shape=input_shape, name='input')

    # Positional Encoding (Learnable)
    position_indices = tf.range(start=0, limit=input_shape[0], delta=1)
    positional_embedding = Dense(input_shape[1])(tf.expand_dims(position_indices, axis=1))
    x = inputs + positional_embedding  # Broadcasting addition

    # Transformer Encoder Blocks
    for _ in range(num_transformer_blocks):
        x = transformer_encoder(
            x, 
            head_size=head_size, 
            num_heads=num_heads, 
            ff_dim=ff_dim, 
            dropout=dropout
        )

    # Global Average Pooling
    x = GlobalAveragePooling1D()(x)
    x = Dropout(dropout)(x)
    x = Dense(128, activation="relu")(x)
    x = Dropout(dropout)(x)

    # Output: Predicted Close Price (Regression)
    close_price_output = Dense(1, activation='linear', name='close_price')(x)

    # Output: Percent Change Classification (7 classes)
    percent_change_output = Dense(num_classes_classification, activation='softmax', name='percent_change_classification')(x)

    # Output: Leg Direction (Assuming Binary Classification: Continue or Reverse)
    leg_direction_output = Dense(2, activation='softmax', name='leg_direction')(x)

    # Output: Leg Length (Regression)
    leg_length_output = Dense(1, activation='linear', name='leg_length')(x)

    # Define the Model
    model = Model(
        inputs=inputs, 
        outputs=[
            close_price_output, 
            percent_change_output, 
            leg_direction_output, 
            leg_length_output
        ]
    )
    
    return model

def compile_multi_output_model(model):
    """
    Compiles the multi-output Transformer model with appropriate loss functions and metrics.
    
    Parameters:
    - model: Keras Model object
    
    Returns:
    - Compiled Keras Model
    """
    model.compile(
        optimizer='adam',
        loss={
            'close_price': 'mse',
            'percent_change_classification': 'sparse_categorical_crossentropy',
            'leg_direction': 'sparse_categorical_crossentropy',
            'leg_length': 'mse'
        },
        metrics={
            'close_price': ['mae'],
            'percent_change_classification': ['accuracy'],
            'leg_direction': ['accuracy'],
            'leg_length': ['mae']
        }
    )
    return model

# ----------------------------
# Main Execution: Complete Script
# ----------------------------

def main():
    # ----------------------------
    # Step 1: Pre-load Historical Data
    # ----------------------------
    print("Pre-loading historical data...")
    pre_load_data(SYMBOLS, INTERVAL, limit=LIMIT)  # Adjust limit as needed

    # ----------------------------
    # Step 2: Update Features for Each Symbol
    # ----------------------------
    print("Updating features for each symbol...")
    for symbol in SYMBOLS:
        update_features(symbol)

    # ----------------------------
    # Step 3: Combine Data from All Symbols
    # ----------------------------
    # For simplicity, we'll concatenate data from all symbols
    with data_lock:
        combined_df = pd.concat([data_frames[symbol] for symbol in SYMBOLS], axis=0).reset_index(drop=True)

    # ----------------------------
    # Step 4: Encode Categorical Features and Scale Numerical Features
    # ----------------------------
    print("Encoding categorical features and scaling numerical features...")
    combined_df, label_encoders, scalers = encode_and_scale(combined_df)

    # ----------------------------
    # Step 5: Prepare Sequences and Targets
    # ----------------------------
    print("Preparing input sequences and targets...")
    X, Y = prepare_sequences(combined_df, seq_length=SEQ_LENGTH, target_steps=TARGET_STEPS)

    print(f"Input shape: {X.shape}")
    print(f"Targets: {{ {', '.join([f'{k}: {v.shape}' for k, v in Y.items()])} }}")

    # ----------------------------
    # Step 6: Split Data into Training and Validation Sets
    # ----------------------------
    # Here, we use an 80-20 split. Adjust as needed.
    X_train, X_val, Y_train, Y_val = train_test_split(
        X, Y, test_size=0.2, shuffle=False
    )

    print(f"Training set shape: {X_train.shape}")
    print(f"Validation set shape: {X_val.shape}")

    # ----------------------------
    # Step 7: Build and Compile the Model
    # ----------------------------
    print("Building and compiling the model...")
    input_shape = X_train.shape[1:]  # (sequence_length, num_features)
    num_classes_classification = len(combined_df['percent_change_classification'].unique())
    model = build_multi_output_transformer_model(
        input_shape=input_shape,
        num_classes_classification=num_classes_classification,
        head_size=256,
        num_heads=4,
        ff_dim=128,
        num_transformer_blocks=2,
        dropout=0.1
    )
    model = compile_multi_output_model(model)

    # ----------------------------
    # Step 8: Train the Model
    # ----------------------------
    print("Training the model...")
    history = model.fit(
        X_train, Y_train,
        validation_data=(X_val, Y_val),
        epochs=20,  # Adjust the number of epochs as needed
        batch_size=32
    )

    # ----------------------------
    # Step 9: Evaluate the Model
    # ----------------------------
    print("Evaluating the model on validation set...")
    evaluation = model.evaluate(X_val, Y_val)
    print(f"Evaluation Results: {evaluation}")

    # ----------------------------
    # Step 10: Save the Model and Encoders/Scalers
    # ----------------------------
    print("Saving the model and encoders/scalers...")
    model.save("multi_output_transformer_model.h5")

    # Save Label Encoders
    with open("label_encoders.pkl", "wb") as f:
        pickle.dump(label_encoders, f)

    # Save Scalers
    with open("scalers.pkl", "wb") as f:
        pickle.dump(scalers, f)

    print("Model and encoders/scalers saved successfully.")

    # ----------------------------
    # Step 11: Visualization (Optional)
    # ----------------------------
    # Plot training and validation losses and accuracies for each output
    print("Plotting training history...")
    outputs = ['close_price', 'percent_change_classification', 'leg_direction', 'leg_length']
    for output in outputs:
        plt.figure(figsize=(12, 4))
        plt.plot(history.history['loss'], label='Train Loss')
        plt.plot(history.history['val_loss'], label='Validation Loss')
        plt.title(f'Loss for {output}')
        plt.xlabel('Epoch')
        plt.ylabel('Loss')
        plt.legend()
        plt.show()

        if 'classification' in output or 'direction' in output:
            plt.figure(figsize=(12, 4))
            plt.plot(history.history[f'{output}_accuracy'], label='Train Accuracy')
            plt.plot(history.history[f'val_{output}_accuracy'], label='Validation Accuracy')
            plt.title(f'Accuracy for {output}')
            plt.xlabel('Epoch')
            plt.ylabel('Accuracy')
            plt.legend()
            plt.show()

    # ----------------------------
    # Step 12: Save Training History
    # ----------------------------
    # Optionally, save the training history for later analysis
    with open("training_history.pkl", "wb") as f:
        pickle.dump(history.history, f)
    
    print("Training history saved successfully.")

    # ----------------------------
    # Step 13: Making Predictions and Extracting Confidence Scores
    # ----------------------------
    # Example function to make predictions
    def make_prediction(model, sequence, label_encoders, scalers):
        """
        Makes a prediction for a single input sequence.

        Args:
            model (tf.keras.Model): Trained Keras model.
            sequence (np.ndarray): Input sequence of shape (1, seq_length, num_features).
            label_encoders (dict): Dictionary of label encoders.
            scalers (dict): Dictionary of scalers.

        Returns:
            dict: Predictions for each output with decoded labels and confidence scores.
        """
        predictions = model.predict(sequence)
        
        # Decode Close Price (Assuming it's normalized, you might need to inverse transform)
        predicted_close_price = predictions[0][0][0]  # Regression output

        # Percent Change Classification
        percent_change_probs = predictions[1][0]
        percent_change_class = np.argmax(percent_change_probs)
        percent_change_confidence = np.max(percent_change_probs)
        percent_change_label = label_encoders['percent_change_classification'].inverse_transform([percent_change_class])[0]

        # Leg Direction
        leg_direction_probs = predictions[2][0]
        leg_direction_class = np.argmax(leg_direction_probs)
        leg_direction_confidence = np.max(leg_direction_probs)
        leg_direction_label = 'Up' if leg_direction_class == 1 else 'Down'  # Assuming 0: Down, 1: Up

        # Leg Length
        predicted_leg_length = predictions[3][0][0]  # Regression output

        return {
            'predicted_close_price': predicted_close_price,
            'percent_change_classification': {
                'label': percent_change_label,
                'confidence': percent_change_confidence
            },
            'leg_direction': {
                'label': leg_direction_label,
                'confidence': leg_direction_confidence
            },
            'predicted_leg_length': predicted_leg_length
        }

    # Example usage:
    if len(X_val) > 0:
        sample_index = 0  # Change as needed
        sample_sequence = X_val[sample_index].reshape(1, SEQ_LENGTH, len(FEATURES))  # Reshape for the model

        prediction = make_prediction(model, sample_sequence, label_encoders, scalers)
        print("\nPrediction Results:")
        print(f"Predicted Close Price: {prediction['predicted_close_price']}")
        print(f"Percent Change Classification: {prediction['percent_change_classification']['label']} "
              f"with confidence {prediction['percent_change_classification']['confidence']:.2f}")
        print(f"Leg Direction: {prediction['leg_direction']['label']} "
              f"with confidence {prediction['leg_direction']['confidence']:.2f}")
        print(f"Predicted Leg Length: {prediction['predicted_leg_length']}")

if __name__ == "__main__":
    main()