# Install required packages
!pip install yfinance pandas numpy beautifulsoup4 requests tqdm deap matplotlib

# Import necessary libraries
import yfinance as yf
import pandas as pd
import numpy as np
import random
from bs4 import BeautifulSoup
import requests
from tqdm import tqdm
import sys
import warnings
import multiprocessing
import matplotlib.pyplot as plt

from deap import base, creator, tools, algorithms

# Suppress warnings for cleaner output
warnings.filterwarnings("ignore")

# 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 generate portfolio weights within constraints
def generate_weights(n, lower=0.10, upper=0.20):
    while True:
        weights = np.random.uniform(lower, upper, n)
        weights /= weights.sum()
        if all(lower <= w <= upper for w in weights):
            return weights

# 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
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}")

# 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: 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]
validation_returns = returns.iloc[split_date_1:split_date_2]
test_returns = returns.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 annualized metrics for each set
def calculate_metrics(returns_set):
    ann_return = returns_set.mean() * 252
    ann_volatility = returns_set.std() * np.sqrt(252)
    cov = returns_set.cov() * 252
    return ann_return, ann_volatility, cov

train_ann_return, train_ann_volatility, train_cov_matrix = calculate_metrics(train_returns)
validation_ann_return, validation_ann_volatility, _ = calculate_metrics(validation_returns)
test_ann_return, test_ann_volatility, test_cov_matrix = calculate_metrics(test_returns)

# 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.25
WEIGHT_MAX_DRAWDOWN = 0.20
WEIGHT_ANNUAL_RETURN = 0.25
WEIGHT_VOLATILITY = 0.15
WEIGHT_SORTINO = 0.15

# Define the evaluation (fitness) function
def evaluate_portfolio(individual):
    selected_tickers = individual[:NUM_ASSETS]
    weights = individual[NUM_ASSETS:]
    
    # Ensure all selected tickers are unique
    if len(set(selected_tickers)) != NUM_ASSETS:
        return -np.inf,  # Invalid individual

    # Convert to numpy array for calculations
    weights = np.array(weights)
    
    # Check if all tickers are present in the covariance matrix
    if not all(ticker in train_cov_matrix.columns for ticker in selected_tickers):
        return -np.inf,  # Invalid individual

    # Calculate portfolio return and volatility using Training Set
    try:
        portfolio_return_train = np.dot(weights, train_ann_return[selected_tickers])
        portfolio_volatility_train = np.sqrt(np.dot(weights, np.dot(train_cov_matrix.loc[selected_tickers, selected_tickers].values, weights)))
    except KeyError as e:
        # In case tickers are not found in covariance matrix
        return -np.inf,
    
    if portfolio_volatility_train == 0:
        return -np.inf,

    # Calculate Sharpe Ratio for Training
    sharpe_ratio_train = (portfolio_return_train - RISK_FREE_RATE) / portfolio_volatility_train

    # Calculate Sortino Ratio for Training
    portfolio_sortino_train = sortino_ratio(train_returns[selected_tickers].dot(weights))

    # Calculate portfolio cumulative returns for Max Drawdown using Training Set
    portfolio_cum_returns_train = (1 + train_returns[selected_tickers].dot(weights)).cumprod()
    portfolio_max_drawdown_train = max_drawdown(portfolio_cum_returns_train)
    
    # Calculate Validation Metrics
    try:
        portfolio_return_val = np.dot(weights, validation_ann_return[selected_tickers])
        portfolio_volatility_val = np.sqrt(np.dot(weights, np.dot(validation_returns[selected_tickers].cov() * 252, weights)))
    except KeyError as e:
        return -np.inf,

    if portfolio_volatility_val == 0:
        return -np.inf,

    # Calculate Sharpe Ratio for Validation
    sharpe_ratio_val = (portfolio_return_val - RISK_FREE_RATE) / portfolio_volatility_val if portfolio_volatility_val != 0 else 0

    # Calculate Sortino Ratio for Validation
    portfolio_sortino_val = sortino_ratio(validation_returns[selected_tickers].dot(weights))
    
    # Calculate portfolio cumulative returns for Max Drawdown using Validation Set
    portfolio_cum_returns_val = (1 + validation_returns[selected_tickers].dot(weights)).cumprod()
    portfolio_max_drawdown_val = max_drawdown(portfolio_cum_returns_val)
    
    # Combine the metrics into a single fitness score
    # Weighted average of training and validation scores
    fitness_train = (WEIGHT_SHARPE * sharpe_ratio_train) \
             - (WEIGHT_MAX_DRAWDOWN * portfolio_max_drawdown_train) \
             + (WEIGHT_ANNUAL_RETURN * portfolio_return_train) \
             - (WEIGHT_VOLATILITY * portfolio_volatility_train) \
             + (WEIGHT_SORTINO * portfolio_sortino_train)
    
    fitness_val = (WEIGHT_SHARPE * sharpe_ratio_val) \
             - (WEIGHT_MAX_DRAWDOWN * portfolio_max_drawdown_val) \
             + (WEIGHT_ANNUAL_RETURN * portfolio_return_val) \
             - (WEIGHT_VOLATILITY * portfolio_volatility_val) \
             + (WEIGHT_SORTINO * portfolio_sortino_val)
    
    # Combine Training and Validation Fitness
    fitness = (fitness_train + fitness_val) / 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
# Selection part: choosing 8 unique tickers
def select_tickers():
    return random.sample(successful_tickers, NUM_ASSETS)

# Allocation part: generating 8 weights between 10% and 20% that sum to 1
def allocate_weights():
    return generate_weights(NUM_ASSETS, LOWER_WEIGHT, UPPER_WEIGHT).tolist()

# Define how each individual is created
def create_individual():
    tickers = select_tickers()
    weights = allocate_weights()
    return creator.Individual(tickers + weights)

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

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

# Custom mutation: either swap a ticker or adjust weights
def mutate_portfolio(individual, indpb=0.1):
    mutation_type = random.choice(['ticker', 'weight'])
    
    if mutation_type == 'ticker':
        # Mutation: swap one 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:
        # Mutation: adjust one weight
        idx = random.randint(NUM_ASSETS, 2 * NUM_ASSETS - 1)
        individual[idx] = random.uniform(LOWER_WEIGHT, UPPER_WEIGHT)
        # Normalize weights to sum to 1
        weights = np.array(individual[NUM_ASSETS:])
        weights /= weights.sum()
        # Ensure constraints
        if not all(LOWER_WEIGHT <= w <= UPPER_WEIGHT for w in weights):
            weights = generate_weights(NUM_ASSETS, LOWER_WEIGHT, UPPER_WEIGHT)
        individual[NUM_ASSETS:] = weights.tolist()
    
    return (individual,)

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

# Determine the number of processes to use
num_processes = max(1, multiprocessing.cpu_count() - 1)  # Leave one core free

# Set up multiprocessing pool
pool = multiprocessing.Pool(processes=num_processes)
toolbox.register("map", pool.map)

# Initialize population
population = toolbox.population(n=POPULATION_SIZE)

# Initialize statistics to keep track
stats = tools.Statistics(lambda ind: ind.fitness.values)
stats.register("avg", np.mean)
stats.register("std", np.std)
stats.register("min", np.min)
stats.register("max", np.max)

# Initialize Hall of Fame to store the best individual
hof = tools.HallOfFame(HALL_OF_FAME_SIZE)

# Define a callback to display better portfolios instantly
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:])
            # Training Metrics
            portfolio_return_train = np.dot(weights, train_ann_return[selected])
            portfolio_volatility_train = np.sqrt(np.dot(weights, np.dot(train_cov_matrix.loc[selected, selected].values, weights)))
            sharpe_ratio_train = (portfolio_return_train - RISK_FREE_RATE) / portfolio_volatility_train if portfolio_volatility_train != 0 else 0
            portfolio_sortino_train = sortino_ratio(train_returns[selected].dot(weights))
            portfolio_cum_returns_train = (1 + train_returns[selected].dot(weights)).cumprod()
            portfolio_max_drawdown_train = max_drawdown(portfolio_cum_returns_train)
            
            # Validation Metrics
            try:
                portfolio_return_val = np.dot(weights, validation_ann_return[selected])
                portfolio_volatility_val = np.sqrt(np.dot(weights, np.dot(validation_returns[selected].cov() * 252, weights)))
            except KeyError as e:
                portfolio_return_val = 0
                portfolio_volatility_val = 0
            sharpe_ratio_val = (portfolio_return_val - RISK_FREE_RATE) / portfolio_volatility_val if portfolio_volatility_val != 0 else 0
            portfolio_sortino_val = sortino_ratio(validation_returns[selected].dot(weights))
            portfolio_cum_returns_val = (1 + validation_returns[selected].dot(weights)).cumprod()
            portfolio_max_drawdown_val = max_drawdown(portfolio_cum_returns_val)
            
            # Display the new best portfolio
            portfolio_df = pd.DataFrame({
                'Ticker': selected,
                'Allocation': [f"{w*100:.2f}%" for w in weights]
            })
            
            print(f"\nGeneration {gen}: New Best Portfolio Found!")
            display(portfolio_df)
            print(f"--- Training Set Performance ---")
            print(f"Annualized Return: {portfolio_return_train:.2f}%")
            print(f"Annualized Volatility: {portfolio_volatility_train:.2f}%")
            print(f"Sharpe Ratio: {sharpe_ratio_train:.2f}")
            print(f"Sortino Ratio: {portfolio_sortino_train:.2f}")
            print(f"Maximum Drawdown: {portfolio_max_drawdown_train:.2%}")
            
            print(f"--- Validation Set Performance ---")
            print(f"Annualized Return: {portfolio_return_val:.2f}%")
            print(f"Annualized Volatility: {portfolio_volatility_val:.2f}%")
            print(f"Sharpe Ratio: {sharpe_ratio_val:.2f}")
            print(f"Sortino Ratio: {portfolio_sortino_val:.2f}")
            print(f"Maximum Drawdown: {portfolio_max_drawdown_val:.2%}")

# Instantiate the callback
callback = PrintBestPortfolio(hof)

# Begin the evolution
print("\nStarting Genetic Algorithm Evolution...\n")
for gen in tqdm(range(1, MAX_GENERATIONS + 1), desc="Generations"):
    # Select the next generation individuals
    offspring = toolbox.select(population, len(population))
    offspring = list(map(toolbox.clone, offspring))
    
    # Apply crossover and mutation on the offspring
    for child1, child2 in zip(offspring[::2], offspring[1::2]):
        if random.random() < P_CROSSOVER:
            toolbox.mate(child1, child2)
            del child1.fitness.values
            del child2.fitness.values
    
    for mutant in offspring:
        if random.random() < P_MUTATION:
            toolbox.mutate(mutant)
            del mutant.fitness.values
    
    # Evaluate the individuals with an invalid fitness
    invalid_ind = [ind for ind in offspring if not ind.fitness.valid]
    fitnesses = toolbox.map(toolbox.evaluate, invalid_ind)
    for ind, fit in zip(invalid_ind, fitnesses):
        ind.fitness.values = fit
    
    # Replace population with offspring
    population[:] = offspring
    
    # Update the hall of fame with the generated individuals
    hof.update(population)
    
    # Gather all the fitnesses in one list and print the stats
    fits = [ind.fitness.values[0] for ind in population]
    
    record = stats.compile(population)
    # Optionally, uncomment the next line to see statistics each generation
    # print(f"Gen {gen}: {record}")
    
    # Callback to print instant best portfolio
    callback(gen, population, fits)
    
print("\nEvolution Complete!\n")

# 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_return_train = np.dot(weights, train_ann_return[selected])
    portfolio_volatility_train = np.sqrt(np.dot(weights, np.dot(train_cov_matrix.loc[selected, selected].values, weights)))
    sharpe_ratio_train = (portfolio_return_train - RISK_FREE_RATE) / portfolio_volatility_train if portfolio_volatility_train != 0 else 0
    portfolio_sortino_train = sortino_ratio(train_returns[selected].dot(weights))
    portfolio_cum_returns_train = (1 + train_returns[selected].dot(weights)).cumprod()
    portfolio_max_drawdown_train = max_drawdown(portfolio_cum_returns_train)
    
    # Validation Metrics
    try:
        portfolio_return_val = np.dot(weights, validation_ann_return[selected])
        portfolio_volatility_val = np.sqrt(np.dot(weights, np.dot(validation_returns[selected].cov() * 252, weights)))
    except KeyError as e:
        portfolio_return_val = 0
        portfolio_volatility_val = 0
    sharpe_ratio_val = (portfolio_return_val - RISK_FREE_RATE) / portfolio_volatility_val if portfolio_volatility_val != 0 else 0
    portfolio_sortino_val = sortino_ratio(validation_returns[selected].dot(weights))
    portfolio_cum_returns_val = (1 + validation_returns[selected].dot(weights)).cumprod()
    portfolio_max_drawdown_val = max_drawdown(portfolio_cum_returns_val)
    
    # Testing Metrics
    portfolio_return_test = np.dot(weights, test_ann_return[selected])
    portfolio_volatility_test = np.sqrt(np.dot(weights, np.dot(test_cov_matrix.loc[selected, selected].values, weights)))
    sharpe_ratio_test = (portfolio_return_test - RISK_FREE_RATE) / portfolio_volatility_test if portfolio_volatility_test != 0 else 0
    portfolio_sortino_test = sortino_ratio(test_returns[selected].dot(weights))
    portfolio_cum_returns_test = (1 + test_returns[selected].dot(weights)).cumprod()
    portfolio_max_drawdown_test = max_drawdown(portfolio_cum_returns_test)
    
    # Composite Fitness Score on Training and Validation Sets
    fitness_train = (WEIGHT_SHARPE * sharpe_ratio_train) \
             - (WEIGHT_MAX_DRAWDOWN * portfolio_max_drawdown_train) \
             + (WEIGHT_ANNUAL_RETURN * portfolio_return_train) \
             - (WEIGHT_VOLATILITY * portfolio_volatility_train) \
             + (WEIGHT_SORTINO * portfolio_sortino_train)
    
    fitness_val = (WEIGHT_SHARPE * sharpe_ratio_val) \
             - (WEIGHT_MAX_DRAWDOWN * portfolio_max_drawdown_val) \
             + (WEIGHT_ANNUAL_RETURN * portfolio_return_val) \
             - (WEIGHT_VOLATILITY * portfolio_volatility_val) \
             + (WEIGHT_SORTINO * portfolio_sortino_val)
    
    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"Annualized Return: {portfolio_return_train:.2f}%")
    print(f"Annualized Volatility: {portfolio_volatility_train:.2f}%")
    print(f"Sharpe Ratio: {sharpe_ratio_train:.2f}")
    print(f"Sortino Ratio: {portfolio_sortino_train:.2f}")
    print(f"Maximum Drawdown: {portfolio_max_drawdown_train:.2%}")
    
    print(f"--- Validation Set Performance ---")
    print(f"Annualized Return: {portfolio_return_val:.2f}%")
    print(f"Annualized Volatility: {portfolio_volatility_val:.2f}%")
    print(f"Sharpe Ratio: {sharpe_ratio_val:.2f}")
    print(f"Sortino Ratio: {portfolio_sortino_val:.2f}")
    print(f"Maximum Drawdown: {portfolio_max_drawdown_val:.2%}")
    
    print(f"--- Testing Set Performance ---")
    print(f"Annualized Return: {portfolio_return_test:.2f}%")
    print(f"Annualized Volatility: {portfolio_volatility_test:.2f}%")
    print(f"Sharpe Ratio: {sharpe_ratio_test:.2f}")
    print(f"Sortino Ratio: {portfolio_sortino_test:.2f}")
    print(f"Maximum Drawdown: {portfolio_max_drawdown_test:.2%}")
    
    # Assess overfitting
    print("\n--- Overfitting Assessment ---")
    # Define thresholds for acceptable performance differences
    threshold_return = 0.05  # 5% difference in return
    threshold_volatility = 0.05  # 5% difference in volatility

    train_val_return_diff = portfolio_return_train - portfolio_return_val
    train_test_return_diff = portfolio_return_train - portfolio_return_test
    train_val_vol_diff = portfolio_volatility_train - portfolio_volatility_val
    train_test_vol_diff = portfolio_volatility_train - portfolio_volatility_test

    overfitting_return = train_test_return_diff > threshold_return
    overfitting_vol = train_test_vol_diff < -threshold_volatility

    if overfitting_return and overfitting_vol:
        print("Warning: Overfitting detected! The portfolio performs significantly better on training 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)
    plt.plot(portfolio_cum_returns_train.index, portfolio_cum_returns_train, label='Training')
    plt.plot(portfolio_cum_returns_val.index, portfolio_cum_returns_val, label='Validation')
    plt.plot(portfolio_cum_returns_test.index, portfolio_cum_returns_test, label='Testing')
    plt.title('Cumulative Returns')
    plt.xlabel('Date')
    plt.ylabel('Cumulative Return')
    plt.legend()
    
    # Drawdowns
    plt.subplot(1, 2, 2)
    plt.plot(portfolio_cum_returns_train.index, (portfolio_cum_returns_train / portfolio_cum_returns_train.cummax()) - 1, label='Training')
    plt.plot(portfolio_cum_returns_val.index, (portfolio_cum_returns_val / portfolio_cum_returns_val.cummax()) - 1, label='Validation')
    plt.plot(portfolio_cum_returns_test.index, (portfolio_cum_returns_test / portfolio_cum_returns_test.cummax()) - 1, label='Testing')
    plt.title('Drawdowns')
    plt.xlabel('Date')
    plt.ylabel('Drawdown')
    plt.legend()
    
    plt.tight_layout()
    plt.show()
    
# Close the multiprocessing pool
pool.close()
pool.join()

refine and refactor the above code, as there are few bugs, e.g. the optimized allocation is exceeding 100% etc, review the code and find all bugs and return the fixed version code to me