# Set random seed for reproducibility
RANDOM_SEED = 42
random.seed(RANDOM_SEED)
np.random.seed(RANDOM_SEED)

# Function to fetch S&P 500 tickers from Wikipedia
def get_sp500_tickers():
    url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies'
    response = requests.get(url)
    if response.status_code != 200:
        raise Exception("Failed to fetch S&P 500 tickers from Wikipedia.")
    soup = BeautifulSoup(response.text, "lxml")
    table = soup.find('table', {'id': 'constituents'})
    tickers = []
    for row in table.findAll('tr')[1:]:
        ticker = row.findAll('td')[0].text.strip()
        # Replace '.' with '-' for tickers like BRK.B
        ticker = ticker.replace('.', '-')
        tickers.append(ticker)
    return tickers

# Function to calculate Maximum Drawdown
def max_drawdown(series):
    try:
        roll_max = series.cummax()
        drawdown = (series - roll_max) / roll_max
        return drawdown.min()
    except:
        return -0.0

# Function to calculate Sortino Ratio
def sortino_ratio(returns, target=0):
    downside = returns[returns < target]
    if len(downside) == 0:
        return 0  # No downside risk; assign a neutral value
    expected_return = returns.mean() * 252
    downside_risk = np.sqrt((downside ** 2).mean()) * np.sqrt(252)
    return (expected_return - target) / downside_risk if downside_risk != 0 else 0

# Function to calculate Conditional Value at Risk (CVaR)
def conditional_value_at_risk(returns, confidence_level=0.95):
    if len(returns) == 0:
        return 0
    var = np.percentile(returns, (1 - confidence_level) * 100)
    cvar = returns[returns <= var].mean()
    return cvar

# Function to generate portfolio weights within constraints
def generate_weights(n, lower=0.10, upper=0.20, max_attempts=1000):
    """
    Generates a list of weights that sum to 1.0 with each weight between lower and upper bounds.
    """
    for _ in range(max_attempts):
        weights = np.random.uniform(lower, upper, n)
        weights /= weights.sum()
        if all(lower <= w <= upper for w in weights):
            return weights
    raise ValueError("Unable to generate weights within constraints after multiple attempts.")

# Function to label economic cycles
def label_economic_cycles(dates):
    recession_periods = [
        ('2001-03-01', '2001-11-30'),
        ('2007-12-01', '2009-06-30'),
        ('2020-02-01', '2020-04-30')
    ]
    labels = []
    for date in dates:
        # Remove timezone information if present
        if date.tzinfo is not None:
            date = date.replace(tzinfo=None)
        label = 'Growth'
        for start, end in recession_periods:
            if pd.to_datetime(start) <= date <= pd.to_datetime(end):
                label = 'Recession'
                break
        labels.append(label)
    return labels

# Parameters
START_DATE = '2000-01-01'
END_DATE = '2024-10-30'
NUM_ASSETS = 8  # Number of stocks in the portfolio
LOWER_WEIGHT = 0.10
UPPER_WEIGHT = 0.20
RISK_FREE_RATE = 0.017  # Approximate risk-free rate (e.g., 10-year US Treasury yield)

# Step 1: Get S&P 500 tickers
print("Fetching S&P 500 tickers...")
tickers = get_sp500_tickers()
print(f"Number of tickers fetched: {len(tickers)}")

# Step 2: Download historical data with dynamic start date
print("Downloading historical data...")
failed_tickers = []
successful_tickers = []
data = pd.DataFrame()

ticker_first_dates = {}

for ticker in tqdm(tickers, desc="Downloading tickers"):
    try:
        df = yf.download(ticker, start=START_DATE, end=END_DATE, progress=False)['Adj Close']
        if df.empty:
            failed_tickers.append(ticker)
        else:
            data[ticker] = df
            successful_tickers.append(ticker)
            ticker_first_dates[ticker] = df.first_valid_index()
    except Exception as e:
        failed_tickers.append(ticker)

print(f"\nTotal tickers with successful data download: {len(successful_tickers)}")
print(f"Total tickers failed to download: {len(failed_tickers)}")
if failed_tickers:
    print(f"Failed tickers: {failed_tickers}")

# Determine the earliest start date among all successful tickers
if ticker_first_dates:
    earliest_date = min(ticker_first_dates.values())
    print(f"Earliest available data starts from: {earliest_date.date()}")
    
    # Adjust the dataset to start from the earliest_date
    data = data.loc[earliest_date:]
    
    # Forward fill to propagate last valid observation
    data.fillna(method='ffill', inplace=True)
    # Backward fill to handle any remaining NaNs at the start
    data.fillna(method='bfill', inplace=True)
    print(f"Data shape after adjusting start date and dropping incomplete data: {data.shape}")
else:
    print("No data available after processing tickers.")
    sys.exit()

# If there are not enough tickers, exit
if len(data.columns) < NUM_ASSETS:
    print("Not enough tickers with complete data to form a portfolio.")
    sys.exit()

# Step 3: Calculate daily returns
returns = data.pct_change().dropna()

# Step 4: Label economic cycles
print("Labeling economic cycles...")
return_dates = returns.index
labels = label_economic_cycles(return_dates)
labels = pd.Series(labels, index=return_dates)  # Convert to Series with matching index

# Step 5: Split data into Training (70%), Validation (15%), Testing (15%)
split_date_1 = int(len(returns) * 0.70)
split_date_2 = int(len(returns) * 0.85)

train_returns = returns.iloc[:split_date_1]  # No 'Economic_Cycle' column
train_labels = labels.iloc[:split_date_1]  # Use iloc to ensure alignment

validation_returns = returns.iloc[split_date_1:split_date_2]
validation_labels = labels.iloc[split_date_1:split_date_2]

test_returns = returns.iloc[split_date_2:]
test_labels = labels.iloc[split_date_2:]

print(f"Training set: {train_returns.shape[0]} days")
print(f"Validation set: {validation_returns.shape[0]} days")
print(f"Testing set: {test_returns.shape[0]} days")

# Calculate compounded multi-year metrics for each set
def calculate_metrics_multi_year(returns_set, labels_set):
    metrics = {}
    # Overall
    cumulative_return = (1 + returns_set.mean(axis=1)).prod() - 1
    metrics['cumulative_return'] = cumulative_return
    
    # Volatility
    volatility = returns_set.mean(axis=1).std() * np.sqrt(252)
    metrics['volatility'] = volatility
    
    # Sharpe Ratio
    sharpe_ratio = (returns_set.mean(axis=1).mean() * 252 - RISK_FREE_RATE) / (returns_set.mean(axis=1).std() * np.sqrt(252))
    metrics['sharpe_ratio'] = sharpe_ratio
    
    # Sortino Ratio
    sortino = {}
    for cycle in ['Growth', 'Recession']:
        cycle_mask = labels_set == cycle
        cycle_returns = returns_set.loc[cycle_mask].mean(axis=1).dropna()
        sortino_cycle = sortino_ratio(cycle_returns)
        sortino[cycle] = sortino_cycle
    metrics['sortino'] = sortino
    
    # CVaR
    cvar = {}
    for cycle in ['Growth', 'Recession']:
        cycle_mask = labels_set == cycle
        cycle_returns = returns_set.loc[cycle_mask].mean(axis=1).dropna()
        cvar_cycle = conditional_value_at_risk(cycle_returns, confidence_level=0.95)
        cvar[cycle] = cvar_cycle
    metrics['cvar'] = cvar
    
    # Maximum Drawdown
    mdd = {}
    for cycle in ['Growth', 'Recession']:
        cycle_mask = labels_set == cycle
        cycle_returns = returns_set.loc[cycle_mask].mean(axis=1).dropna()
        portfolio_cum_returns = (1 + cycle_returns).cumprod()
        mdd_cycle = max_drawdown(portfolio_cum_returns)
        mdd[cycle] = mdd_cycle
    metrics['max_drawdown'] = mdd
    
    return metrics

train_metrics = calculate_metrics_multi_year(train_returns, train_labels)
validation_metrics = calculate_metrics_multi_year(validation_returns, validation_labels)
test_metrics = calculate_metrics_multi_year(test_returns, test_labels)

# Define Genetic Algorithm parameters
POPULATION_SIZE = 500  # Adjusted for memory constraints
P_CROSSOVER = 0.8      # Probability for crossover
P_MUTATION = 0.2       # Probability for mutating an individual
MAX_GENERATIONS = 100  # Increased number of generations for better convergence
HALL_OF_FAME_SIZE = 1  # Number of best individuals to keep

# Define the weights for each optimization objective
WEIGHT_SHARPE = 0.30
WEIGHT_SORTINO_GROWTH = 0.15
WEIGHT_SORTINO_RECESSION = 0.15
WEIGHT_VOLATILITY = 0.10
WEIGHT_ANNUALIZED_RETURN = 0.20
WEIGHT_MAX_DRAWDOWN = 0.10

# Function to calculate Compound Annual Growth Rate (CAGR)
def calculate_cagr(returns):
    total_return = (1 + returns).prod()
    n = len(returns) / 252  # Assuming daily data
    return total_return**(1/n) - 1

# Define the evaluation (fitness) function
def evaluate_portfolio(individual):
    selected_tickers = individual[:NUM_ASSETS]
    weights = np.array(individual[NUM_ASSETS:])
    
    # Ensure all selected tickers are unique and weights are valid
    if len(set(selected_tickers)) != NUM_ASSETS or not np.isclose(weights.sum(), 1.0) or \
       not all(LOWER_WEIGHT <= w <= UPPER_WEIGHT for w in weights) or \
       not all(ticker in returns.columns for ticker in selected_tickers):
        return -np.inf,

    # Calculate portfolio metrics using Training Set
    try:
        portfolio_returns_train = train_returns[selected_tickers].dot(weights)
        portfolio_cagr_train = calculate_cagr(portfolio_returns_train)
        portfolio_volatility_train = portfolio_returns_train.std() * np.sqrt(252)
        sharpe_ratio_train = (portfolio_returns_train.mean() * 252 - RISK_FREE_RATE) / portfolio_volatility_train if portfolio_volatility_train != 0 else 0
        sortino_growth_train = sortino_ratio(portfolio_returns_train[train_labels == 'Growth'])
        sortino_recession_train = sortino_ratio(portfolio_returns_train[train_labels == 'Recession'])
        portfolio_max_drawdown_growth_train = max_drawdown(portfolio_returns_train[train_labels == 'Growth'].cumprod())
        portfolio_max_drawdown_recession_train = max_drawdown(portfolio_returns_train[train_labels == 'Recession'].cumprod())
    except:
        return -np.inf,

    # Combine the metrics into a single fitness score
    fitness = (
        WEIGHT_SHARPE * sharpe_ratio_train +
        WEIGHT_SORTINO_GROWTH * sortino_growth_train +
        WEIGHT_SORTINO_RECESSION * sortino_recession_train +
        WEIGHT_VOLATILITY * (-portfolio_volatility_train) +
        WEIGHT_ANNUALIZED_RETURN * portfolio_cagr_train +
        WEIGHT_MAX_DRAWDOWN * (portfolio_max_drawdown_growth_train + portfolio_max_drawdown_recession_train) / 2
    )
    
    return fitness,

# Create the DEAP framework
creator.create("FitnessMax", base.Fitness, weights=(1.0,))  # Single objective, maximize
creator.create("Individual", list, fitness=creator.FitnessMax)

toolbox = base.Toolbox()

# Attribute generators
def select_tickers():
    return random.sample(successful_tickers, NUM_ASSETS)

def allocate_weights():
    return generate_weights(NUM_ASSETS, LOWER_WEIGHT, UPPER_WEIGHT).tolist()

def create_individual():
    tickers_selected = select_tickers()
    weights_allocated = allocate_weights()
    return creator.Individual(tickers_selected + weights_allocated)

toolbox.register("individual", create_individual)
toolbox.register("population", tools.initRepeat, list, toolbox.individual)

# Custom Crossover
def cxTickersWeights(ind1, ind2):
    # Separate tickers and weights
    tickers1, weights1 = ind1[:NUM_ASSETS], ind1[NUM_ASSETS:]
    tickers2, weights2 = ind2[:NUM_ASSETS], ind2[NUM_ASSETS:]
    
    # Crossover tickers using one-point crossover
    cx_point = random.randint(1, NUM_ASSETS - 1)
    new_tickers1 = tickers1[:cx_point] + [ticker for ticker in tickers2[cx_point:] if ticker not in tickers1[:cx_point]]
    new_tickers2 = tickers2[:cx_point] + [ticker for ticker in tickers1[cx_point:] if ticker not in tickers2[:cx_point]]
    
    # Fill the remaining tickers to maintain NUM_ASSETS
    available_tickers1 = list(set(successful_tickers) - set(new_tickers1))
    available_tickers2 = list(set(successful_tickers) - set(new_tickers2))
    
    while len(new_tickers1) < NUM_ASSETS:
        new_tickers1.append(random.choice(available_tickers1))
        available_tickers1.remove(new_tickers1[-1])
    
    while len(new_tickers2) < NUM_ASSETS:
        new_tickers2.append(random.choice(available_tickers2))
        available_tickers2.remove(new_tickers2[-1])
    
    ind1[:NUM_ASSETS], ind2[:NUM_ASSETS] = new_tickers1, new_tickers2
    
    # Crossover weights using arithmetic crossover
    new_weights1 = (np.array(weights1) + np.array(weights2)) / 2
    new_weights2 = (np.array(weights1) + np.array(weights2)) / 2
    
    # Enforce weight constraints
    try:
        new_weights1 = generate_weights(NUM_ASSETS, LOWER_WEIGHT, UPPER_WEIGHT)
    except ValueError:
        new_weights1 = allocate_weights()
    
    try:
        new_weights2 = generate_weights(NUM_ASSETS, LOWER_WEIGHT, UPPER_WEIGHT)
    except ValueError:
        new_weights2 = allocate_weights()
    
    ind1[NUM_ASSETS:], ind2[NUM_ASSETS:] = new_weights1.tolist(), new_weights2.tolist()
    
    return ind1, ind2

toolbox.register("mate", cxTickersWeights)

# Genetic operators
toolbox.register("evaluate", evaluate_portfolio)

def mutate_portfolio(individual, indpb=0.2):
    mutation_type = random.choice(['ticker', 'weight'])
    
    if mutation_type == 'ticker':
        idx = random.randint(0, NUM_ASSETS - 1)
        current_ticker = individual[idx]
        available_tickers = list(set(successful_tickers) - set(individual[:NUM_ASSETS]))
        if available_tickers:
            new_ticker = random.choice(available_tickers)
            individual[idx] = new_ticker
    else:
        try:
            new_weights = generate_weights(NUM_ASSETS, LOWER_WEIGHT, UPPER_WEIGHT)
            individual[NUM_ASSETS:] = new_weights.tolist()
        except ValueError:
            pass
    
    return (individual,)

toolbox.register("mutate", mutate_portfolio, indpb=0.2)
toolbox.register("select", tools.selTournament, tournsize=3)

# Define the callback to print instant best portfolio
class PrintBestPortfolio:
    def __init__(self, hof):
        self.hof = hof
        self.best_fitness = -np.inf
    
    def __call__(self, gen, population, fitnesses):
        current_best = max(fitnesses)
        if current_best > self.best_fitness:
            self.best_fitness = current_best
            best_ind = tools.selBest(population, 1)[0]
            selected = best_ind[:NUM_ASSETS]
            weights = np.array(best_ind[NUM_ASSETS:])
            
            try:
                portfolio_returns_train = train_returns[selected].dot(weights)
                portfolio_cagr_train = calculate_cagr(portfolio_returns_train)
                portfolio_volatility_train = portfolio_returns_train.std() * np.sqrt(252)
                sharpe_ratio_train = (portfolio_returns_train.mean() * 252 - RISK_FREE_RATE) / portfolio_volatility_train if portfolio_volatility_train != 0 else 0
                sortino_growth_train = sortino_ratio(portfolio_returns_train[train_labels == 'Growth'])
                sortino_recession_train = sortino_ratio(portfolio_returns_train[train_labels == 'Recession'])
                portfolio_max_drawdown_growth_train = max_drawdown(portfolio_returns_train[train_labels == 'Growth'].cumprod())
                portfolio_max_drawdown_recession_train = max_drawdown(portfolio_returns_train[train_labels == 'Recession'].cumprod())
                
                print(f"\nGeneration {gen}: New Best Portfolio Found!")
                
                # Display the metrics
                print(f"CAGR (Train): {portfolio_cagr_train*100:.2f}%")
                print(f"Volatility (Train): {portfolio_volatility_train*100:.2f}%")
                print(f"Sharpe Ratio (Train): {sharpe_ratio_train:.2f}")
                print(f"Sortino Ratio (Growth): {sortino_growth_train:.2f}")
                print(f"Sortino Ratio (Recession): {sortino_recession_train:.2f}")
                print(f"Max Drawdown (Growth): {portfolio_max_drawdown_growth_train:.2%}")
                print(f"Max Drawdown (Recession): {portfolio_max_drawdown_recession_train:.2%}")
                
                portfolio_df = pd.DataFrame({
                    'Ticker': selected,
                    'Allocation': [f"{w*100:.2f}%" for w in weights]
                })
                print(portfolio_df)
            except Exception as e:
                print(f"Error in calculating metrics: {e}")

# Set up multiprocessing pool
num_processes = max(1, multiprocessing.cpu_count() - 1)  # Leave one core free
pool = multiprocessing.Pool(processes=num_processes)
toolbox.register("map", pool.map)

# Retrieve and display the best portfolio found
if len(hof) > 0:
    best_ind = hof[0]
    selected = best_ind[:NUM_ASSETS]
    weights = np.array(best_ind[NUM_ASSETS:])
    
    # Training Metrics
    portfolio_returns_train = train_returns[selected].dot(weights)
    portfolio_cagr_train = calculate_cagr(portfolio_returns_train)
    portfolio_volatility_train = portfolio_returns_train.std() * np.sqrt(252)
    sharpe_ratio_train = (portfolio_returns_train.mean() * 252 - RISK_FREE_RATE) / portfolio_volatility_train if portfolio_volatility_train != 0 else 0
    sortino_growth_train = sortino_ratio(portfolio_returns_train[train_labels == 'Growth'])
    sortino_recession_train = sortino_ratio(portfolio_returns_train[train_labels == 'Recession'])
    annualized_return_train = portfolio_returns_train.mean() * 252
    portfolio_cum_returns_growth_train = (1 + portfolio_returns_train[train_labels == 'Growth']).cumprod()
    portfolio_cum_returns_recession_train = (1 + portfolio_returns_train[train_labels == 'Recession']).cumprod()
    portfolio_max_drawdown_growth_train = max_drawdown(portfolio_cum_returns_growth_train)
    portfolio_max_drawdown_recession_train = max_drawdown(portfolio_cum_returns_recession_train)
    
    # Validation Metrics
    try:
        portfolio_returns_val = validation_returns[selected].dot(weights)
        portfolio_cagr_val = calculate_cagr(portfolio_returns_val)
        portfolio_volatility_val = portfolio_returns_val.std() * np.sqrt(252)
        sharpe_ratio_val = (portfolio_returns_val.mean() * 252 - RISK_FREE_RATE) / portfolio_volatility_val if portfolio_volatility_val != 0 else 0
        sortino_growth_val = sortino_ratio(portfolio_returns_val[validation_labels == 'Growth'])
        sortino_recession_val = sortino_ratio(portfolio_returns_val[validation_labels == 'Recession'])
        annualized_return_val = portfolio_returns_val.mean() * 252
        portfolio_cum_returns_growth_val = (1 + portfolio_returns_val[validation_labels == 'Growth']).cumprod()
        portfolio_cum_returns_recession_val = (1 + portfolio_returns_val[validation_labels == 'Recession']).cumprod()
        portfolio_max_drawdown_growth_val = max_drawdown(portfolio_cum_returns_growth_val)
        portfolio_max_drawdown_recession_val = max_drawdown(portfolio_cum_returns_recession_val)
    except:
        portfolio_cagr_val = 0
        portfolio_volatility_val = 0
        sharpe_ratio_val = 0
        sortino_growth_val = 0
        sortino_recession_val = 0
        annualized_return_val = 0
        portfolio_max_drawdown_growth_val = 0
        portfolio_max_drawdown_recession_val = 0
    
    # Testing Metrics
    try:
        portfolio_returns_test = test_returns[selected].dot(weights)
        portfolio_cagr_test = calculate_cagr(portfolio_returns_test)
        portfolio_volatility_test = portfolio_returns_test.std() * np.sqrt(252)
        sharpe_ratio_test = (portfolio_returns_test.mean() * 252 - RISK_FREE_RATE) / portfolio_volatility_test if portfolio_volatility_test != 0 else 0
        sortino_test_growth = sortino_ratio(portfolio_returns_test[test_labels == 'Growth'])
        sortino_test_recession = sortino_ratio(portfolio_returns_test[test_labels == 'Recession'])
        annualized_return_test = portfolio_returns_test.mean() * 252
        portfolio_cum_returns_growth_test = (1 + portfolio_returns_test[test_labels == 'Growth']).cumprod()
        portfolio_cum_returns_recession_test = (1 + portfolio_returns_test[test_labels == 'Recession']).cumprod()
        portfolio_max_drawdown_growth_test = max_drawdown(portfolio_cum_returns_growth_test)
        portfolio_max_drawdown_recession_test = max_drawdown(portfolio_cum_returns_recession_test)
    except:
        portfolio_cagr_test = 0
        portfolio_volatility_test = 0
        sharpe_ratio_test = 0
        sortino_test_growth = 0
        sortino_test_recession = 0
        annualized_return_test = 0
        portfolio_max_drawdown_growth_test = 0
        portfolio_max_drawdown_recession_test = 0
    
    # Composite Fitness Score on Training and Validation Sets
    fitness_train = (
        WEIGHT_SHARPE * sharpe_ratio_train +
        WEIGHT_SORTINO_GROWTH * sortino_growth_train +
        WEIGHT_SORTINO_RECESSION * sortino_recession_train +
        WEIGHT_VOLATILITY * (-portfolio_volatility_train) +
        WEIGHT_ANNUALIZED_RETURN * annualized_return_train +
        WEIGHT_MAX_DRAWDOWN * (portfolio_max_drawdown_growth_train + portfolio_max_drawdown_recession_train) / 2
    )
    
    fitness_val = (
        WEIGHT_SHARPE * sharpe_ratio_val +
        WEIGHT_SORTINO_GROWTH * sortino_growth_val +
        WEIGHT_SORTINO_RECESSION * sortino_recession_val +
        WEIGHT_VOLATILITY * (-portfolio_volatility_val) +
        WEIGHT_ANNUALIZED_RETURN * annualized_return_val +
        WEIGHT_MAX_DRAWDOWN * (portfolio_max_drawdown_growth_val + portfolio_max_drawdown_recession_val) / 2
    )

    fitness = (fitness_train + fitness_val) / 2
    
    portfolio_df = pd.DataFrame({
        'Ticker': selected,
        'Allocation': [f"{w*100:.2f}%" for w in weights]
    })
    
    print("Optimal Portfolio Found:")
    display(portfolio_df)
    print(f"--- Training Set Performance ---")
    print(f"CAGR: {portfolio_cagr_train*100:.2f}%")
    print(f"Annualized Volatility: {portfolio_volatility_train*100:.2f}%")
    print(f"Sharpe Ratio: {sharpe_ratio_train:.2f}")
    print(f"Sortino Ratio (Growth): {sortino_growth_train:.2f}")
    print(f"Sortino Ratio (Recession): {sortino_recession_train:.2f}")
    print(f"Annualized Return: {annualized_return_train*100:.2f}%")
    print(f"Maximum Drawdown (Growth): {portfolio_max_drawdown_growth_train:.2%}")
    print(f"Maximum Drawdown (Recession): {portfolio_max_drawdown_recession_train:.2%}")
    
    print(f"--- Validation Set Performance ---")
    print(f"CAGR: {portfolio_cagr_val*100:.2f}%")
    print(f"Annualized Volatility: {portfolio_volatility_val*100:.2f}%")
    print(f"Sharpe Ratio: {sharpe_ratio_val:.2f}")
    print(f"Sortino Ratio (Growth): {sortino_growth_val:.2f}")
    print(f"Sortino Ratio (Recession): {sortino_recession_val:.2f}")
    print(f"Annualized Return: {annualized_return_val*100:.2f}%")
    print(f"Maximum Drawdown (Growth): {portfolio_max_drawdown_growth_val:.2%}")
    print(f"Maximum Drawdown (Recession): {portfolio_max_drawdown_recession_val:.2%}")
    
    print(f"--- Testing Set Performance ---")
    print(f"CAGR: {portfolio_cagr_test*100:.2f}%")
    print(f"Annualized Volatility: {portfolio_volatility_test*100:.2f}%")
    print(f"Sharpe Ratio: {sharpe_ratio_test:.2f}")
    print(f"Sortino Ratio (Growth): {sortino_test_growth:.2f}")
    print(f"Sortino Ratio (Recession): {sortino_test_recession:.2f}")
    print(f"Annualized Return: {annualized_return_test*100:.2f}%")
    print(f"Maximum Drawdown (Growth): {portfolio_max_drawdown_growth_test:.2%}")
    print(f"Maximum Drawdown (Recession): {portfolio_max_drawdown_recession_test:.2%}")
    
    # Assess overfitting
    print("\n--- Overfitting Assessment ---")
    # Define thresholds for acceptable performance differences
    threshold_cagr = 0.05  # Threshold for CAGR difference
    
    train_val_cagr_diff = portfolio_cagr_train - portfolio_cagr_val
    train_test_cagr_diff = portfolio_cagr_train - portfolio_cagr_test if 'portfolio_cagr_test' in locals() else 0
    train_val_vol_diff = portfolio_volatility_train - portfolio_volatility_val
    train_test_vol_diff = portfolio_volatility_train - portfolio_volatility_test if 'portfolio_volatility_test' in locals() else 0

    overfitting_cagr = train_test_cagr_diff > threshold_cagr
    overfitting_vol = train_test_vol_diff < -threshold_volatility

    if overfitting_cagr and overfitting_vol:
        print("Warning: Overfitting detected! The portfolio performs significantly better on training and validation data compared to testing data.")
    else:
        print("No significant overfitting detected. The portfolio generalizes well to unseen data.")
    
    # Plot cumulative returns for visual assessment
    plt.figure(figsize=(14, 7))
    
    # Training Cumulative Returns
    plt.subplot(1, 2, 1)
    if not portfolio_cum_returns_growth_train.empty:
        plt.plot(portfolio_cum_returns_growth_train.index, portfolio_cum_returns_growth_train, label='Training Growth')
    if not portfolio_cum_returns_recession_train.empty:
        plt.plot(portfolio_cum_returns_recession_train.index, portfolio_cum_returns_recession_train, label='Training Recession')
    if not portfolio_cum_returns_growth_val.empty:
        plt.plot(portfolio_cum_returns_growth_val.index, portfolio_cum_returns_growth_val, label='Validation Growth')
    if not portfolio_cum_returns_recession_val.empty:
        plt.plot(portfolio_cum_returns_recession_val.index, portfolio_cum_returns_recession_val, label='Validation Recession')
    if 'portfolio_cum_returns_growth_test' in locals() and not portfolio_cum_returns_growth_test.empty:
        plt.plot(portfolio_cum_returns_growth_test.index, portfolio_cum_returns_growth_test, label='Testing Growth')
    if 'portfolio_cum_returns_recession_test' in locals() and not portfolio_cum_returns_recession_test.empty:
        plt.plot(portfolio_cum_returns_recession_test.index, portfolio_cum_returns_recession_test, label='Testing Recession')
    plt.title('Cumulative Returns')
    plt.xlabel('Date')
    plt.ylabel('Cumulative Return')
    plt.legend()
    
    # Drawdowns
    plt.subplot(1, 2, 2)
    if not portfolio_cum_returns_growth_train.empty:
        plt.plot(portfolio_cum_returns_growth_train.index, (portfolio_cum_returns_growth_train / portfolio_cum_returns_growth_train.cummax()) - 1, label='Training Growth Drawdown')
    if not portfolio_cum_returns_recession_train.empty:
        plt.plot(portfolio_cum_returns_recession_train.index, (portfolio_cum_returns_recession_train / portfolio_cum_returns_recession_train.cummax()) - 1, label='Training Recession Drawdown')
    if not portfolio_cum_returns_growth_val.empty:
        plt.plot(portfolio_cum_returns_growth_val.index, (portfolio_cum_returns_growth_val / portfolio_cum_returns_growth_val.cummax()) - 1, label='Validation Growth Drawdown')
    if not portfolio_cum_returns_recession_val.empty:
        plt.plot(portfolio_cum_returns_recession_val.index, (portfolio_cum_returns_recession_val / portfolio_cum_returns_recession_val.cummax()) - 1, label='Validation Recession Drawdown')
    if 'portfolio_cum_returns_growth_test' in locals() and not portfolio_cum_returns_growth_test.empty:
        plt.plot(portfolio_cum_returns_growth_test.index, (portfolio_cum_returns_growth_test / portfolio_cum_returns_growth_test.cummax()) - 1, label='Testing Growth Drawdown')
    if 'portfolio_cum_returns_recession_test' in locals() and not portfolio_cum_returns_recession_test.empty:
        plt.plot(portfolio_cum_returns_recession_test.index, (portfolio_cum_returns_recession_test / portfolio_cum_returns_recession_test.cummax()) - 1, label='Testing Recession Drawdown')
    plt.title('Drawdowns')
    plt.xlabel('Date')
    plt.ylabel('Drawdown')
    plt.legend()
    
    plt.tight_layout()
    plt.show()

refactor the above code and also fixing the genetic algorithm of optimization as it seems not working properly