# ----------------------------
# Helper Functions
# ----------------------------

def get_company_name(ticker_df, ticker):
    match = ticker_df[ticker_df['Ticker'] == ticker]
    if not match.empty:
        return match.iloc[0]['Company Name']
    return "Unknown"

def format_asset_option(ticker, company_name):
    return f"{ticker} - {company_name}"

# Global SSL bypass setup for requests
# Suppress only the InsecureRequestWarning, if you are bypassing SSL
warnings.simplefilter('ignore', InsecureRequestWarning)
requests.packages.urllib3.disable_warnings()  # Suppresses SSL warnings (use cautiously)

def calculate_risk_factor_attribution(returns, factors, annualization_factor=252):
    """
    Decompose portfolio returns by risk factors using linear regression and provide
    comments on the factor contributions.

    Parameters:
    - returns (pd.Series): Portfolio returns.
    - factors (pd.DataFrame): DataFrame where each column represents a risk factor.
    - annualization_factor (int): Annualization factor (252 for daily, 12 for monthly returns).

    Returns:
    - pd.DataFrame: Factor names, their contribution percentages, and explanatory comments.
    - str: Error message if any exception occurs, else None.
    """
    try:
        # Ensure returns has a name, or assign one if missing
        if returns.name is None:
            returns = returns.rename("Portfolio_Returns")

        # Convert returns to a DataFrame
        returns = returns.to_frame()

        # Ensure both returns and factors are in decimal format
        if returns['Portfolio_Returns'].max() > 1:
            returns = returns / 100

        if factors.max().max() > 1:
            factors = factors / 100

        # Adjust returns to excess returns by subtracting RF (if it exists in factors)
        if 'RF' in factors.columns:
            returns['Portfolio_Returns'] = returns['Portfolio_Returns'] - factors['RF']
            factors = factors.drop(columns=['RF'])

        # Align the data using an inner join and drop any remaining NaN values
        aligned_data = pd.merge(returns, factors, left_index=True, right_index=True, how='inner').dropna()

        # Check that we have data after merging
        if aligned_data.empty:
            raise ValueError("No overlapping dates between returns and factors after dropping NaNs.")

        # Prepare X and y for regression
        X = aligned_data[factors.columns]
        X = sm.add_constant(X)  # Add intercept
        y = aligned_data['Portfolio_Returns']

        # Run the regression model
        model = sm.OLS(y, X).fit()
        coefficients = model.params.drop('const', errors='ignore')

        # Calculate factor contributions (non-annualized)
        factor_means = X.mean().drop('const', errors='ignore')
        contributions = coefficients * factor_means

        # Calculate total return
        total_return = returns['Portfolio_Returns'].mean()

        # Check if total_return is zero to avoid division by zero
        if np.isclose(total_return, 0):
            st.warning("Total portfolio return is zero. Cannot compute contribution percentages.")
            attribution_df = pd.DataFrame({
                'Factor': contributions.index,
                'Contribution (%)': [0.0] * len(contributions)
            })
        else:
            # Calculate percentage contributions
            attribution_df = pd.DataFrame({
                'Factor': contributions.index,
                'Contribution (%)': (contributions / total_return * 100).round(2)
            })

        # Remove the constant term if it exists
        if 'const' in attribution_df['Factor'].values:
            attribution_df = attribution_df[attribution_df['Factor'] != 'const']

        # Use full names for factors
        factor_full_names = {
            'Mkt-RF': 'Market Risk Premium',
            'SMB': 'Small Minus Big',
            'HML': 'High Minus Low'
        }
        attribution_df['Factor'] = attribution_df['Factor'].replace(factor_full_names)

        # Generate detailed comments based on factor contributions
        comments = []
        for _, row in attribution_df.iterrows():
            factor_name = row['Factor']
            contribution = row['Contribution (%)']

            # Market Risk Premium (Mkt-RF) Analysis
            if factor_name == 'Market Risk Premium':
                if contribution > 75:
                    comments.append(f"{factor_name} = {contribution}%: Extremely high sensitivity to market movements.")
                elif contribution > 50:
                    comments.append(f"{factor_name} = {contribution}%: High sensitivity to overall market movements.")
                elif contribution > 30:
                    comments.append(f"{factor_name} = {contribution}%: Notable sensitivity to market risk.")
                elif contribution > 10:
                    comments.append(f"{factor_name} = {contribution}%: Moderate exposure to market risk.")
                else:
                    comments.append(f"{factor_name} = {contribution}%: Low exposure to market risk.")

            # Small Minus Big (SMB) Analysis
            elif factor_name == 'Small Minus Big':
                if contribution > 5:
                    comments.append(f"{factor_name} = {contribution}%: Strong tilt towards small-cap stocks.")
                elif contribution > 0:
                    comments.append(f"{factor_name} = {contribution}%: Mild preference for small-cap stocks.")
                elif contribution > -5:
                    comments.append(f"{factor_name} = {contribution}%: Slight tilt towards larger-cap stocks.")
                else:
                    comments.append(f"{factor_name} = {contribution}%: Clear preference for large-cap stocks.")

            # High Minus Low (HML) Analysis
            elif factor_name == 'High Minus Low':
                if contribution > 5:
                    comments.append(f"{factor_name} = {contribution}%: Strong tilt toward value stocks.")
                elif contribution > 0:
                    comments.append(f"{factor_name} = {contribution}%: Slight positive tilt toward value stocks.")
                elif contribution > -5:
                    comments.append(f"{factor_name} = {contribution}%: Minor negative tilt toward growth stocks.")
                else:
                    comments.append(f"{factor_name} = {contribution}%: Clear preference for growth stocks.")

        # Add comments to the DataFrame
        attribution_df['Comment'] = comments

        return attribution_df, None

    except Exception as e:
        error_message = f"Error calculating risk factor attribution: {e}"
        return pd.DataFrame(), error_message

# Fetch actual factor data from Fama-French or another data source
def fetch_fama_french_factors(start_date, end_date):
    try:
        # Fetch daily Fama-French factors
        ff_data = pdr.get_data_famafrench('F-F_Research_Data_Factors_daily', start=start_date, end=end_date)
        if not ff_data:
            st.error("Fama-French data not found.")
            return pd.DataFrame()

        factors = ff_data[0]
        factors = factors.rename(columns=lambda x: x.strip())  # Remove any leading/trailing spaces
        factors /= 100  # Convert percentage returns to decimal format

        # Fill missing data by forward and backward filling
        factors = factors.fillna(method='ffill').fillna(method='bfill')

        # Check for necessary columns
        required_columns = {'Mkt-RF', 'SMB', 'HML', 'RF'}
        if not required_columns.issubset(factors.columns):
            missing = required_columns - set(factors.columns)
            st.error(f"Missing factor columns: {', '.join(missing)}")
            return pd.DataFrame()

        # Removed debugging outputs
        # st.write("Fama-French factors fetched successfully:")
        # st.dataframe(factors.head())

        return factors
    except Exception as e:
        st.error(f"Error fetching factor data: {e}")
        return pd.DataFrame()

def calculate_final_score(primary_metrics, comparison_metrics):
    metrics_to_compare = [
        'Annualized Return (CAGR)',
        'Sharpe Ratio',
        'Sortino Ratio',
        'Treynor Ratio',
        'Calmar Ratio',
        'Alpha (annualized)',
        'Information Ratio',
        'Modigliani–Modigliani Measure',
        'Upside Capture Ratio',
        'Gain/Loss Ratio'
    ]
    metric_weights = {
        'Annualized Return (CAGR)': 15,
        'Sharpe Ratio': 15,
        'Sortino Ratio': 10,
        'Treynor Ratio': 10,
        'Calmar Ratio': 10,
        'Alpha (annualized)': 10,
        'Information Ratio': 10,
        'Modigliani–Modigliani Measure': 10,
        'Upside Capture Ratio': 5,
        'Gain/Loss Ratio': 5
    }

    score = 0
    total = 0

    for metric in metrics_to_compare:
        primary = primary_metrics.get(metric, "N/A")
        comparison = comparison_metrics.get(metric, "N/A")
        if primary != "N/A" and comparison != "N/A":
            primary_val = float(primary.strip('%')) if '%' in primary else float(primary)
            comparison_val = float(comparison.strip('%')) if '%' in comparison else float(comparison)
            weight = metric_weights.get(metric, 1)
            if comparison_val == 0:
                st.warning(f"Comparison metric '{metric}' has a value of zero. Skipping this metric to avoid division by zero.")
                continue
            if primary_val > comparison_val:
                score += weight * (primary_val / comparison_val)
            else:
                score += weight * (primary_val / comparison_val) * 0.5  # Partial credit
            total += weight
    return (score / total) * 100 if total > 0 else 0

def plot_growth_comparison(cum_returns, benchmark_cum_returns):
    try:
        # Align the indices to ensure matching dates
        common_index = cum_returns.index.intersection(benchmark_cum_returns.index)
        cum_returns = cum_returns.loc[common_index]
        benchmark_cum_returns = benchmark_cum_returns.loc[common_index]

        if cum_returns.empty or benchmark_cum_returns.empty:
            st.warning("No overlapping data to plot growth comparison.")
            return

        df = pd.DataFrame({
            'Date': cum_returns.index,
            'Portfolio': cum_returns.values,
            'Benchmark': benchmark_cum_returns.values
        })

        fig = px.line(
            df,
            x='Date',
            y=['Portfolio', 'Benchmark'],
            title='Growth Comparison',
            labels={'value': 'Cumulative Returns', 'Date': 'Date'},
            hover_data={'Date': '|%B %d, %Y'},  # Enhanced hover format
            template='plotly_dark'  # Use dark template for better contrast
        )
        fig.update_layout(
            hovermode='x unified',
            xaxis=dict(rangeslider=dict(visible=True), type='date'),
        )
        fig.update_traces(line=dict(width=2))
        st.plotly_chart(fig, use_container_width=True, config={
            'scrollZoom': True
        })
    except Exception as e:
        st.error(f"Error plotting growth comparison: {e}")

def plot_drawdown_comparison(drawdown_portfolio, drawdown_benchmark):
    try:
        df = pd.DataFrame({
            'Portfolio Drawdown': drawdown_portfolio,
            'Benchmark Drawdown': drawdown_benchmark
        })
        fig = px.line(df, title='Drawdown Comparison', labels={'value': 'Drawdown (%)', 'index': 'Date'}, template='plotly_dark')
        fig.update_layout(
            hovermode='x unified',
            xaxis=dict(rangeslider=dict(visible=True), type='date'),
        )
        fig.update_traces(line=dict(width=2))
        st.plotly_chart(fig, use_container_width=True, config={
            'scrollZoom': True})
    except Exception as e:
        st.error(f"Error plotting drawdown comparison: {e}")

def plot_cagr_over_time(cum_returns, time_frames=['Weekly', 'Monthly', 'Quarterly', 'Annually']):
    try:
        frequency_map = {
            'Weekly': 'W',
            'Monthly': 'M',
            'Quarterly': 'Q',
            'Annually': 'Y'
        }
        fig = go.Figure()
        for tf in time_frames:
            freq = frequency_map.get(tf)
            if not freq:
                continue
            rolled = cum_returns.resample(freq).last()
            years = (rolled.index[-1] - rolled.index[0]).days / 365.25
            if years <= 0:
                st.warning(f"Not enough data to calculate {tf} CAGR.")
                continue
            cagr = (rolled / rolled.iloc[0]) ** (1/years) - 1
            fig.add_trace(go.Scatter(x=rolled.index, y=cagr, mode='lines', name=f'{tf} CAGR'))
        fig.update_layout(
            title='CAGR Over Multiple Time Frames',
            xaxis_title='Date',
            yaxis_title='CAGR',
            hovermode='x unified'
        )
        st.plotly_chart(fig, use_container_width=True)
    except Exception as e:
        st.error(f"Error plotting CAGR over time: {e}")

def backtest(weights, prices, rebalance_freq='M', broker_fee=0.0, debug=False):
    try:
        # Calculate returns and drop any NaN values
        returns = prices.pct_change().dropna()
        if returns.empty:
            st.error("Returns data is empty after calculating percentage changes.")
            return pd.Series(dtype=float), pd.Series(dtype=float)

        # Calculate portfolio returns
        portfolio_returns = returns.dot(weights)
        if not isinstance(portfolio_returns, pd.Series):
            portfolio_returns = portfolio_returns.squeeze()

        # Identify rebalancing dates
        if rebalance_freq == 'D':
            rebalance_dates = returns.index
        else:
            rebalance_dates = returns.resample(rebalance_freq).last().dropna().index

        # Debug: Show rebalancing dates
        if debug:
            st.write(f"Rebalancing Dates: {rebalance_dates.tolist()}")

        # Ensure rebalance_dates are in the portfolio_returns index
        valid_rebalance_dates = rebalance_dates.intersection(portfolio_returns.index)
        if debug:
            st.write(f"Valid Rebalancing Dates: {valid_rebalance_dates.tolist()}")

        # Apply broker fees on rebalancing dates
        if not valid_rebalance_dates.empty:
            portfolio_returns.loc[valid_rebalance_dates] -= broker_fee / 100  # Convert to decimal
        else:
            st.warning("No valid rebalancing dates found within the returns data.")

        # Calculate cumulative returns
        cum_returns = (1 + portfolio_returns).cumprod()
        if cum_returns.empty:
            st.error("Cumulative returns are empty. Check the data and allocations.")
            return pd.Series(dtype=float), pd.Series(dtype=float)

        # Debug: Show cumulative returns
        if debug:
            st.write("Cumulative Returns:")
            st.write(cum_returns)

        return portfolio_returns, cum_returns
    except Exception as e:
        st.error(f"Error during backtesting: {e}")
        return pd.Series(dtype=float), pd.Series(dtype=float)

def calculate_simple_metrics(returns, cum_returns, start_balance=10000):
    metrics = {}
    metrics['Start Balance'] = f"${start_balance:,.2f}"
    if not cum_returns.empty:
        try:
            end_balance = start_balance * cum_returns.iloc[-1]
            metrics['End Balance'] = f"${end_balance:,.2f}"
            start_date = cum_returns.index[0]
            end_date = cum_returns.index[-1]
            days = (end_date - start_date).days
            years = days / 365.25
            cagr = (end_balance / start_balance) ** (1 / years) - 1 if years > 0 else np.nan
            metrics['Annualized Return (CAGR)'] = f"{cagr * 100:.2f}%" if not np.isnan(cagr) else "N/A"
            yearly_returns = returns.resample('Y').apply(lambda x: (1 + x).prod() - 1)
            best_year = yearly_returns.max() * 100 if not yearly_returns.empty else np.nan
            worst_year = yearly_returns.min() * 100 if not yearly_returns.empty else np.nan
            metrics['Best Year'] = f"{best_year:.2f}%" if not np.isnan(best_year) else "N/A"
            metrics['Worst Year'] = f"{worst_year:.2f}%" if not np.isnan(worst_year) else "N/A"
            metrics['Arithmetic Mean (Monthly)'] = f"{returns.mean() * 100:.2f}%"
            metrics['Arithmetic Mean (Annualized)'] = f"{returns.mean() * 252 * 100:.2f}%"
            metrics['Geometric Mean (Monthly)'] = f"{(np.exp(np.log1p(returns).mean()) - 1) * 100:.2f}%"
            metrics['Geometric Mean (Annualized)'] = f"{(np.exp(np.log1p(returns).mean() * 252) - 1) * 100:.2f}%"
            metrics['Standard Deviation (Monthly)'] = f"{returns.std() * 100:.2f}%"
            metrics['Standard Deviation (Annualized)'] = f"{returns.std() * np.sqrt(252) * 100:.2f}%"
            metrics['Downside Deviation (Monthly)'] = f"{returns[returns < 0].std() * 100:.2f}%"
            metrics['Maximum Drawdown'] = f"{drawdown(cum_returns) * 100:.2f}%" if not cum_returns.empty else "N/A"
            metrics['Sharpe Ratio'] = f"{calculate_sharpe_ratio(returns, 0.02):.2f}" if not cum_returns.empty else "N/A"
            metrics['Sortino Ratio'] = f"{calculate_sortino_ratio(returns, 0.02):.2f}" if not cum_returns.empty else "N/A"
            metrics['Gain/Loss Ratio'] = f"{calculate_gain_loss_ratio(returns):.2f}" if not cum_returns.empty else "N/A"
            metrics['Skewness'] = f"{skew(returns):.2f}" if not returns.empty else "N/A"
            metrics['Excess Kurtosis'] = f"{kurtosis(returns):.2f}" if not returns.empty else "N/A"
            metrics['Safe Withdrawal Rate'] = f"{calculate_safe_withdrawal_rate(returns):.6f}%" if not returns.empty else "N/A"
            metrics['Perpetual Withdrawal Rate'] = f"{calculate_perpetual_withdrawal_rate(returns):.6f}%" if not returns.empty else "N/A"
            metrics['Positive Periods'] = calculate_positive_periods(returns) if not returns.empty else "N/A"
        except Exception as e:
            st.error(f"Error calculating simple metrics: {e}")
            for key in ['End Balance', 'Annualized Return (CAGR)', 'Best Year', 'Worst Year',
                        'Arithmetic Mean (Monthly)', 'Arithmetic Mean (Annualized)',
                        'Geometric Mean (Monthly)', 'Geometric Mean (Annualized)',
                        'Standard Deviation (Monthly)', 'Standard Deviation (Annualized)',
                        'Downside Deviation (Monthly)', 'Maximum Drawdown',
                        'Sharpe Ratio', 'Sortino Ratio', 'Gain/Loss Ratio',
                        'Skewness', 'Excess Kurtosis', 'Safe Withdrawal Rate',
                        'Perpetual Withdrawal Rate', 'Positive Periods']:
                metrics[key] = "N/A"
    else:
        metrics['End Balance'] = "N/A"
        metrics['Annualized Return (CAGR)'] = "N/A"
        metrics['Best Year'] = "N/A"
        metrics['Worst Year'] = "N/A"
        metrics['Arithmetic Mean (Monthly)'] = "N/A"
        metrics['Arithmetic Mean (Annualized)'] = "N/A"
        metrics['Geometric Mean (Monthly)'] = "N/A"
        metrics['Geometric Mean (Annualized)'] = "N/A"
        metrics['Standard Deviation (Monthly)'] = "N/A"
        metrics['Standard Deviation (Annualized)'] = "N/A"
        metrics['Downside Deviation (Monthly)'] = "N/A"
        metrics['Maximum Drawdown'] = "N/A"
        metrics['Sharpe Ratio'] = "N/A"
        metrics['Sortino Ratio'] = "N/A"
        metrics['Gain/Loss Ratio'] = "N/A"
        metrics['Skewness'] = "N/A"
        metrics['Excess Kurtosis'] = "N/A"
        metrics['Safe Withdrawal Rate'] = "N/A"
        metrics['Perpetual Withdrawal Rate'] = "N/A"
        metrics['Positive Periods'] = "N/A"

    return metrics

def calculate_sharpe_ratio(returns, rf=0.02):
    """
    Calculate the Sharpe Ratio for a given set of returns.

    Parameters:
    - returns (pd.Series): Daily returns of the portfolio.
    - rf (float): Risk-free rate (default is 2%).

    Returns:
    - float: Sharpe Ratio.
    """
    excess_return = returns.mean() * 252 - rf
    std_dev = returns.std() * np.sqrt(252)
    return excess_return / std_dev if std_dev != 0 else np.nan

def calculate_sortino_ratio(returns, rf=0.02):
    excess_return = returns.mean() * 252 - rf
    downside_std = returns[returns < 0].std() * np.sqrt(252)
    return excess_return / downside_std if downside_std != 0 else np.nan

def calculate_treynor_ratio(returns, benchmark_returns, rf=0.02):
    beta = calculate_beta(returns, benchmark_returns)
    excess_return = returns.mean() * 252 - rf
    return excess_return / beta if beta != 0 else np.nan

def calculate_calmar_ratio(returns, cum_returns):
    annual_return = returns.mean() * 252
    max_dd = drawdown(cum_returns)
    return annual_return / abs(max_dd) if max_dd != 0 else np.nan

def calculate_beta(returns, benchmark_returns):
    if returns.empty or benchmark_returns.empty:
        return np.nan
    covariance_matrix = np.cov(returns, benchmark_returns)
    covariance = covariance_matrix[0, 1]
    benchmark_variance = covariance_matrix[1, 1]
    return covariance / benchmark_variance if benchmark_variance != 0 else np.nan

def calculate_alpha(returns, benchmark_returns, rf=0.02):
    beta = calculate_beta(returns, benchmark_returns)
    portfolio_return = returns.mean() * 252
    benchmark_return = benchmark_returns.mean() * 252
    return portfolio_return - (rf + beta * (benchmark_return - rf)) if not np.isnan(beta) else np.nan

def calculate_r_squared(returns, benchmark_returns):
    if returns.empty or benchmark_returns.empty:
        return np.nan
    covariance = np.cov(returns, benchmark_returns)
    var_port = covariance[0,0]
    var_bench = covariance[1,1]
    cov = covariance[0,1]
    return (cov ** 2) / (var_port * var_bench) if var_port !=0 and var_bench !=0 else np.nan

def calculate_information_ratio(returns, benchmark_returns):
    if returns.empty or benchmark_returns.empty:
        return np.nan
    active_return = (returns.mean() - benchmark_returns.mean()) * 252
    tracking_error = calculate_tracking_error(returns, benchmark_returns)
    return active_return / tracking_error if tracking_error != 0 else np.nan

def generate_recommendations(allocations, available_selected):
    """
    Generate basic portfolio recommendations based on current allocations.

    Parameters:
    - allocations (list of float): Current allocation percentages.
    - available_selected (list of str): List of selected asset tickers.

    Returns:
    - list of str: Recommendations
    """
    recommendations = []
    max_alloc = max(allocations)
    min_alloc = min(allocations)
    total_alloc = sum(allocations)

    if max_alloc > 40.0:
        idx = allocations.index(max_alloc)
        ticker = available_selected[idx]
        recommendations.append(f"🔹 Consider reducing the allocation to **{ticker}** since it constitutes **{max_alloc:.2f}%** of your portfolio.")

    if min_alloc < 5.0:
        idx = allocations.index(min_alloc)
        ticker = available_selected[idx]
        recommendations.append(f"🔸 Consider increasing the allocation to **{ticker}** to at least **5.00%** for better diversification.")

    if total_alloc < 100.0:
        recommendations.append("🔹 Consider allocating the remaining funds to additional assets to reach a total of **100%**.")

    return recommendations

def get_common_benchmarks(selected_tickers):
    # Define benchmark suggestions based on asset sectors or indices
    sp500 = {"AAPL", "MSFT", "GOOGL", "AMZN", "META", "TSLA"}  # Example S&P 500 tech companies
    nasdaq_tech = {"AAPL", "MSFT", "GOOGL", "AMZN", "META", "TSLA", "NVDA", "AMD"}

    selected_set = set(selected_tickers)

    if selected_set.issubset(sp500):
        return {
            "S&P 500": "^GSPC",
            "Dow Jones Industrial Average": "^DJI",
            "Russell 2000": "^RUT",
            "Custom": "CUSTOM"
        }
    elif selected_set.issubset(nasdaq_tech):
        return {
            "NASDAQ Composite": "^IXIC",
            "QQQ (Invesco QQQ ETF)": "QQQ",
            "Custom": "CUSTOM"
        }
    else:
        return {
            "S&P 500": "^GSPC",
            "NASDAQ Composite": "^IXIC",
            "Dow Jones Industrial Average": "^DJI",
            "Russell 2000": "^RUT",
            "Custom": "CUSTOM"
        }

def calculate_tracking_error(returns, benchmark_returns):
    if returns.empty or benchmark_returns.empty:
        return np.nan
    return np.std((returns - benchmark_returns)) * np.sqrt(252)

def calculate_performance_attribution(returns, weights):
    try:
        if isinstance(returns, pd.Series):
            returns = returns.to_frame('Asset')  # Convert Series to DataFrame with a default column name

        if len(weights) != len(returns.columns):
            st.error(f"Number of weights ({len(weights)}) does not match number of assets ({len(returns.columns)}).")
            return pd.DataFrame()

        annual_returns = returns.mean() * 252
        contributions = annual_returns * weights
        attribution_df = pd.DataFrame({
            'Asset': returns.columns,
            'Contribution (%)': (contributions / contributions.sum() * 100).round(2)
        }).sort_values(by='Contribution (%)', ascending=False)
        return attribution_df
    except Exception as e:
        st.error(f"Error calculating performance attribution: {e}")
        return pd.DataFrame()

def calculate_active_return(returns, benchmark_returns):
    if returns.empty or benchmark_returns.empty:
        return np.nan
    return (returns.mean() - benchmark_returns.mean()) * 252 * 100

def calculate_gain_loss_ratio(returns):
    gains = returns[returns > 0].sum()
    losses = -returns[returns < 0].sum()
    return gains / losses if losses != 0 else np.nan

def drawdown(cum_returns):
    if cum_returns.empty:
        return np.nan
    peak = cum_returns.expanding(min_periods=1).max()
    dd = (cum_returns / peak) - 1
    return dd.min()

def calculate_capture_ratio(returns, benchmark_returns, upside=True):
    if returns.empty or benchmark_returns.empty:
        return np.nan
    mask = benchmark_returns > 0 if upside else benchmark_returns < 0
    if mask.sum() == 0:
        return np.nan
    portfolio = returns[mask]
    benchmark = benchmark_returns[mask]
    return (portfolio.sum() / benchmark.sum()) * 100 if benchmark.sum() != 0 else np.nan

def calculate_safe_withdrawal_rate(returns):
    if returns.empty:
        return np.nan
    return (returns.mean() / returns.std()) * 100

def calculate_perpetual_withdrawal_rate(returns):
    if returns.empty:
        return np.nan
    return (returns.mean() / returns.std()) * 100

def calculate_positive_periods(returns):
    if returns.empty:
        return "N/A"
    positive = returns > 0
    return f"{positive.sum()} out of {len(returns)} ({(positive.sum()/len(returns))*100:.2f}%)"

def calculate_modigliani_miller(returns, benchmark_returns, rf=0.02):
    sharpe = calculate_sharpe_ratio(returns, rf)
    alpha = calculate_alpha(returns, benchmark_returns, rf)
    return alpha / sharpe if sharpe != 0 and not np.isnan(alpha) else np.nan

def get_drawdown_details(cum_returns):
    drawdowns = []
    if cum_returns.empty:
        return drawdowns
    peak = cum_returns.iloc[0]
    peak_date = cum_returns.index[0]
    trough = cum_returns.iloc[0]
    trough_date = cum_returns.index[0]
    for date, value in cum_returns.items():
        if value > peak:
            if trough < peak:
                recovery = cum_returns[cum_returns >= peak].loc[trough_date:]
                if not recovery.empty:
                    recovery_date = recovery.index[0]
                    if recovery_date > trough_date:
                        recovery_time = (recovery_date - trough_date).days
                        underwater_period = (recovery_date - peak_date).days
                        drawdowns.append({
                            'Start': peak_date.strftime('%b %Y'),
                            'End': trough_date.strftime('%b %Y'),
                            'Length': f"{(trough_date - peak_date).days // 30} months",
                            'Recovery By': recovery_date.strftime('%b %Y'),
                            'Recovery Time': f"{recovery_time // 30} months",
                            'Underwater Period': f"{underwater_period // 30} months",
                            'Drawdown': f"{((trough / peak) - 1) * 100:.2f}%"
                        })
                    else:
                        drawdowns.append({
                            'Start': peak_date.strftime('%b %Y'),
                            'End': trough_date.strftime('%b %Y'),
                            'Length': f"{(trough_date - peak_date).days // 30} months",
                            'Recovery By': "Not Recovered",
                            'Recovery Time': "N/A",
                            'Underwater Period': "N/A",
                            'Drawdown': f"{((trough / peak) - 1) * 100:.2f}%"
                        })
            peak = value
            peak_date = date
            trough = value
            trough_date = date
        elif value < trough:
            trough = value
            trough_date = date
    if trough < peak:
        drawdowns.append({
            'Start': peak_date.strftime('%b %Y'),
            'End': trough_date.strftime('%b %Y'),
            'Length': f"{(trough_date - peak_date).days // 30} months",
            'Recovery By': "Not Recovered",
            'Recovery Time': "N/A",
            'Underwater Period': "N/A",
            'Drawdown': f"{((trough / peak) - 1) * 100:.2f}%"
        })
    try:
        drawdowns_sorted = sorted(drawdowns, key=lambda x: float(x['Drawdown'].strip('%')), reverse=False)
    except:
        drawdowns_sorted = []
    drawdowns_sorted = drawdowns_sorted[:10]
    for idx, dd in enumerate(drawdowns_sorted, start=1):
        dd['Rank'] = idx
    drawdowns_final = []
    for dd in drawdowns_sorted:
        drawdowns_final.append({
            'Rank': dd['Rank'],
            'Start': dd['Start'],
            'End': dd['End'],
            'Length': dd['Length'],
            'Recovery By': dd['Recovery By'],
            'Recovery Time': dd['Recovery Time'],
            'Underwater Period': dd['Underwater Period'],
            'Drawdown': dd['Drawdown']
        })
    return drawdowns_final

def optimize_portfolio(
    returns,
    benchmark_returns=None,
    objectives=['sharpe'],
    rf=0.02,
    max_weight=1.0,
    min_weight=0.0,
    target_return=None
):
    """
    Optimize a portfolio based on specified objectives.

    Parameters:
    - returns (pd.DataFrame or np.ndarray): Historical returns of assets.
    - benchmark_returns (pd.Series or np.ndarray, optional): Returns of the benchmark index.
    - objectives (list of str): Objectives to optimize.
        Options include 'sharpe', 'min_variance', 'max_return', 'min_drawdown', 'maximize_alpha', 'minimize_beta'.
    - rf (float): Risk-free rate for alpha calculation.
    - max_weight (float): Maximum weight per asset.
    - min_weight (float): Minimum weight per asset.
    - target_return (float, optional): Target return for the portfolio.

    Returns:
    - np.ndarray: Optimized asset weights or None if optimization fails.
    """

    # Define helper functions (ensure these are implemented elsewhere)
    def calculate_beta(portfolio_returns, benchmark_returns):
        covariance = np.cov(portfolio_returns, benchmark_returns)[0, 1]
        variance = np.var(benchmark_returns)
        return covariance / variance if variance != 0 else 0

    def calculate_var(portfolio_returns, confidence_level=0.95):
        if not isinstance(portfolio_returns, np.ndarray):
            portfolio_returns = np.array(portfolio_returns)
        return np.percentile(portfolio_returns, (1 - confidence_level) * 100)

    def calculate_cvar(portfolio_returns, confidence_level=0.95):
        var = calculate_var(portfolio_returns, confidence_level)
        portfolio_returns = np.array(portfolio_returns)
        return portfolio_returns[portfolio_returns <= var].mean()

    # Define individual objective functions
    def sharpe_ratio(weights):
        portfolio_return = np.dot(returns.mean(), weights) * 252
        portfolio_volatility = np.sqrt(np.dot(weights.T, np.dot(returns.cov() * 252, weights)))
        return -portfolio_return / portfolio_volatility if portfolio_volatility > 1e-6 else 0  # Negative for maximization

    def min_variance(weights):
        return np.dot(weights.T, np.dot(returns.cov() * 252, weights))

    def max_return(weights):
        return -np.dot(returns.mean(), weights) * 252  # Negative for maximization

    def min_drawdown(weights):
        # Simplistic drawdown minimization using expected shortfall (CVaR)
        portfolio_returns = returns.dot(weights)
        cvar = calculate_cvar(portfolio_returns, 0.95)
        return cvar  # Minimizing CVaR as a proxy for drawdown

    # Initialize list of objective functions
    objective_functions = []

    # Iterate through selected objectives and append corresponding functions
    for obj in objectives:
        if obj == 'sharpe':
            objective_functions.append(sharpe_ratio)
        elif obj == 'min_variance':
            objective_functions.append(min_variance)
        elif obj == 'max_return':
            objective_functions.append(max_return)
        elif obj == 'min_drawdown':
            objective_functions.append(min_drawdown)
        elif obj == 'maximize_alpha':
            if benchmark_returns is None:
                st.error("benchmark_returns must be provided for 'maximize_alpha' objective.")
                return None

            benchmark_return = benchmark_returns.mean() * 252  # Assuming daily returns annualized
            def maximize_alpha(weights):
                portfolio_return = np.dot(returns.mean(), weights) * 252
                portfolio_volatility = np.sqrt(np.dot(weights.T, np.dot(returns.cov() * 252, weights)))
                portfolio_returns = returns.dot(weights)
                beta = calculate_beta(portfolio_returns, benchmark_returns)
                alpha = portfolio_return - (rf + beta * (benchmark_return - rf))
                return -alpha  # Negative for maximization

            objective_functions.append(maximize_alpha)
        elif obj == 'minimize_beta':
            if benchmark_returns is None:
                st.error("benchmark_returns must be provided for 'minimize_beta' objective.")
                return None

            def minimize_beta(weights):
                portfolio_returns = returns.dot(weights)
                beta = calculate_beta(portfolio_returns, benchmark_returns)
                return beta

            objective_functions.append(minimize_beta)
        else:
            st.error(f"Invalid optimization objective: {obj}")
            return None

    # Composite objective function: weighted sum of individual objectives
    def composite_objective(weights):
        return sum(fn(weights) for fn in objective_functions)

    # Define constraints
    constraints = [
        {'type': 'eq', 'fun': lambda x: np.sum(x) - 1},
    ]

    if target_return is not None:
        def target_return_constraint(x):
            return np.dot(x, returns.mean()) * 252 - target_return
        constraints.append({
            'type': 'ineq',
            'fun': target_return_constraint
        })

    # Define bounds for weights
    bounds = tuple((min_weight, max_weight) for _ in range(returns.shape[1]))

    # Initial guess (equally distributed weights)
    initial_guess = np.array([1.0 / returns.shape[1]] * returns.shape[1])

    # Perform optimization
    result = minimize(
        composite_objective,
        initial_guess,
        method='SLSQP',
        bounds=bounds,
        constraints=constraints
    )

    if result.success:
        optimized_weights = result.x
        portfolio_volatility = np.sqrt(np.dot(optimized_weights.T, np.dot(returns.cov() * 252, optimized_weights)))

        if portfolio_volatility <= 1e-6:
            st.error("Optimized portfolio has near-zero volatility. Optimization constraints may be too restrictive.")
            return None

        return optimized_weights
    else:
        st.error("Optimization failed. Try adjusting your constraints or target return.")
        return None

def monte_carlo_simulation(returns, num_simulations=1000, periods=252, mean_returns=None, cov_matrix=None,
                           mean_reversion=False, mean_reversion_speed=0.1, long_term_mean=None,
                           time_varying_vol=False, vol_change_rate=0.0,
                           stress_shocks=None, stress_period=None,
                           return_distribution='normal'):
    if mean_returns is None:
        mean_returns = returns.mean()
    if cov_matrix is None:
        cov_matrix = returns.cov()

    num_assets = returns.shape[1]
    available_selected = returns.columns.tolist()

    def simulate():
        weights = np.random.random(num_assets)
        weights /= np.sum(weights)
        simulated_prices = [1]  # Start with initial price of 1
        current_mean = mean_returns.copy()
        current_vol = np.sqrt(np.diag(cov_matrix))
        for t in range(periods):
            if mean_reversion:
                current_mean += mean_reversion_speed * (long_term_mean - current_mean)
            if time_varying_vol:
                current_vol += vol_change_rate
            adjusted_cov_matrix = np.outer(current_vol, current_vol) * np.corrcoef(returns.T)
            if return_distribution == 'log-normal':
                simulated_returns = np.random.lognormal(mean=np.log(1 + current_mean), sigma=current_vol) - 1
            else:
                simulated_returns = np.random.multivariate_normal(current_mean, adjusted_cov_matrix)

            # Apply stress shocks if applicable
            if stress_shocks and t == stress_period:
                for idx, ticker in enumerate(available_selected):
                    simulated_returns[idx] += stress_shocks.get(ticker, 0)

            portfolio_return = np.dot(simulated_returns, weights)
            simulated_prices.append(simulated_prices[-1] * (1 + portfolio_return))
        cumulative_return = simulated_prices[-1] - 1
        return cumulative_return

    portfolio_returns = Parallel(n_jobs=-1)(
        delayed(simulate)() for _ in range(num_simulations)
    )

    return np.array(portfolio_returns)

def plot_efficient_frontier(returns, num_portfolios=1000, rf=0.0):
    try:
        num_assets = returns.shape[1] if returns.ndim > 1 else 1

        def generate_portfolio(returns, rf):
            weights = np.random.random(num_assets)
            weights /= np.sum(weights)
            portfolio_return = np.sum(returns.mean() * weights) * 252
            portfolio_std_dev = np.sqrt(np.dot(weights.T, np.dot(returns.cov() * 252, weights)))
            sharpe_ratio = (portfolio_return - rf) / portfolio_std_dev if portfolio_std_dev > 0 else 0
            return portfolio_std_dev, portfolio_return, sharpe_ratio

        results = Parallel(n_jobs=-1)(
            delayed(generate_portfolio)(returns, rf) for _ in range(num_portfolios)
        )

        # Filter out incomplete results (where any element is NaN)
        valid_results = [res for res in results if all(not np.isnan(x) for x in res)]

        if len(valid_results) == 0:
            st.warning("No valid portfolios to plot on the Efficient Frontier.")
            return

        results = np.array(valid_results).T
        ef_df = pd.DataFrame({
            'Std Dev': results[0],
            'Return': results[1],
            'Sharpe Ratio': results[2]
        })
        fig = px.scatter(
            ef_df,
            x='Std Dev',
            y='Return',
            color='Sharpe Ratio',
            color_continuous_scale='Viridis',
            title='Efficient Frontier',
            labels={
                'Std Dev': 'Annualized Volatility (Std Dev)',
                'Return': 'Annualized Return',
                'Sharpe Ratio': 'Sharpe Ratio'
            },
            hover_data={
                'Sharpe Ratio': ':.2f',
                'Std Dev': ':.2f',
                'Return': ':.2f'
            },
            template='plotly_dark'
        )

        # Add annotation for the maximum Sharpe ratio portfolio
        max_sharpe_idx = ef_df['Sharpe Ratio'].idxmax()
        max_sharpe = ef_df.loc[max_sharpe_idx]
        fig.add_annotation(
            x=max_sharpe['Std Dev'],
            y=max_sharpe['Return'],
            text="Max Sharpe",
            showarrow=True,
            arrowhead=1
        )

        # Check for Optimized Portfolio in Session State
        optimized_weights = st.session_state.backtest_results.get('optimized_weights', None)
        optimized_return = st.session_state.backtest_results.get('optimized_return', None)
        optimized_volatility = st.session_state.backtest_results.get('optimized_volatility', None)
        optimized_sharpe = st.session_state.backtest_results.get('optimized_sharpe', None)

        if optimized_weights is not None and optimized_return is not None and optimized_volatility is not None and optimized_sharpe is not None:
            if optimized_volatility > 1e-6 and np.isfinite(optimized_sharpe):
                # Add optimized portfolio to the existing Efficient Frontier plot
                fig.add_trace(go.Scatter(
                    x=[optimized_volatility],
                    y=[optimized_return],
                    mode='markers+text',
                    marker=dict(color='red', size=14, symbol='star'),
                    name='Optimized Portfolio',
                    text=["Optimized"],
                    textposition="top center",
                    hoverinfo='text'
                ))
                fig.add_annotation(
                    x=optimized_volatility,
                    y=optimized_return,
                    text="Optimized",
                    showarrow=True,
                    arrowhead=2,
                    ax=0,
                    ay=-40
                )

        st.plotly_chart(fig, use_container_width=True, config={
            'responsive': True,
            'scrollZoom': True,
            'displayModeBar': True
        })
    except Exception as e:
        st.error(f"Error plotting efficient frontier: {e}")

def generate_portfolio(returns, rf):
    try:
        weights = np.random.random(returns.shape[1])
        weights /= np.sum(weights)
        portfolio_return = np.dot(weights, returns.mean()) * 252
        portfolio_volatility = np.sqrt(np.dot(weights.T, np.dot(returns.cov() * 252, weights)))
        sharpe_ratio = (portfolio_return - rf) / portfolio_volatility if portfolio_volatility > 0 else 0
        return portfolio_volatility, portfolio_return, sharpe_ratio
    except Exception as e:
        st.error(f"Error generating portfolio: {e}")
        return np.nan, np.nan, np.nan  # Return NaNs to indicate failure

def plot_allocation_pie(weights, assets, title='Portfolio Allocation', hover_info=None):
    allocation_df = pd.DataFrame({
        'Asset': assets,
        'Weight': weights
    })

    # Configure hover data based on hover_info parameter
    if hover_info == "percent+name":
        hover_data = ['Weight']  # Changed from dict to list
    elif hover_info == "name":
        hover_data = []  # No additional hover data
    elif hover_info == "percent":
        hover_data = ['Weight']  # Changed from dict to list
    else:
        hover_data = []  # No additional hover data

    fig = px.pie(
        allocation_df,
        names='Asset',
        values='Weight',
        title=title,
        color='Asset',
        color_discrete_sequence=px.colors.qualitative.Set3,
        hover_data=hover_data
    )
    st.plotly_chart(fig, use_container_width=True)

def plot_rolling_cagr(cum_returns, window=252):
    try:
        rolling_years = window / 252  # Assuming daily data
        rolling_cagr = (cum_returns / cum_returns.shift(window)) ** (1/rolling_years) - 1
        fig = px.line(
            rolling_cagr.dropna(),
            title=f'Rolling {int(rolling_years)}-Year CAGR',
            labels={'x': 'Date', 'y': 'Rolling CAGR'},
            template='plotly_dark'
        )
        st.plotly_chart(fig, use_container_width=True)
    except Exception as e:
        st.error(f"Error plotting rolling CAGR: {e}")

def plot_correlation_heatmap(returns):
    try:
        if isinstance(returns, pd.DataFrame) and returns.shape[1] >= 2:
            valid_columns = [col for col in returns.columns if returns[col].nunique() > 1 and returns[col].notna().sum() >= 10]
            if len(valid_columns) < 2:
                st.warning("Correlation heatmap requires at least two assets with variability and sufficient data.")
                return
            corr_matrix = returns[valid_columns].corr()
            fig = px.imshow(
                corr_matrix,
                title='Asset Correlation Heatmap',
                labels={'x': 'Asset', 'y': 'Asset', 'color': 'Correlation'},
                color_continuous_scale='Portland',  # Updated color scale
                zmin=-1,
                zmax=1,
                text_auto=True,
                aspect="auto",
                template='plotly_dark'               # Dark theme for better contrast
            )
            fig.update_xaxes(side="top")  # Move x-axis labels to the top for better readability
            st.plotly_chart(fig, use_container_width=True)
        elif isinstance(returns, pd.Series) and returns.nunique() > 1:
            st.warning("Correlation heatmap requires at least two assets with variability.")
        else:
            st.warning("Correlation heatmap requires at least two assets with variability.")
    except Exception as e:
        st.error(f"Error plotting correlation heatmap: {e}")

def plot_rolling_metrics(returns, windows=[30, 90, 180, 252]):
    metrics = {}
    for window in windows:
        rolling_return = returns.rolling(window).mean() * 252
        rolling_vol = returns.rolling(window).std() * np.sqrt(252)
        rolling_sharpe = (rolling_return - 0.02) / rolling_vol
        metrics[f'Rolling {window}-Day Volatility'] = rolling_vol
        metrics[f'Rolling {window}-Day Sharpe Ratio'] = rolling_sharpe
    fig = go.Figure()
    for metric_name, metric_series in metrics.items():
        fig.add_trace(go.Scatter(x=metric_series.index, y=metric_series, mode='lines', name=metric_name))
    fig.update_layout(
        title='Rolling Metrics Over User-Defined Periods',
        xaxis_title='Date',
        yaxis_title='Value',
        hovermode='x unified'
    )
    st.plotly_chart(fig, use_container_width=True)

def plot_risk_return_attribution(returns, weights):
    try:
        if isinstance(returns, pd.Series):
            asset_returns = returns.mean() * 252
            asset_volatility = returns.std() * np.sqrt(252)
            asset_contribution = weights[0] * asset_volatility if len(weights) > 0 else 0
            hover_names = [returns.name] if returns.name else ['Asset']
            x = [asset_volatility]
            y = [asset_returns]
            size = [asset_contribution]
            color = [weights[0]]
        else:
            asset_returns = returns.mean() * 252
            asset_volatility = returns.std() * np.sqrt(252)
            asset_contribution = weights * asset_volatility
            hover_names = returns.columns.tolist()
            x = asset_volatility.values
            y = asset_returns.values
            size = asset_contribution
            color = weights

        fig = px.scatter(
            x=x,
            y=y,
            size=size,
            color=color,
            hover_name=hover_names,
            title='Risk-Return Attribution',
            labels={'x': 'Annualized Volatility', 'y': 'Annualized Return', 'color': 'Weight (%)'},
            size_max=60,
            color_continuous_scale='Viridis'
        )
        fig.update_layout(template='plotly_dark')
        st.plotly_chart(fig, use_container_width=True)
    except Exception as e:
        st.error(f"Error plotting risk-return attribution: {e}")

def plot_var_cvar_distribution(portfolio_returns, var, cvar):
    fig = px.histogram(portfolio_returns, nbins=50, title='Returns Distribution with VaR and CVaR',
                       labels={'value': 'Returns', 'count': 'Frequency'})
    fig.add_vline(x=var, line_dash="dash", line_color="red", annotation_text=f"VaR: {var:.2f}", annotation_position="top left")
    fig.add_vline(x=cvar, line_dash="dash", line_color="blue", annotation_text=f"CVaR: {cvar:.2f}", annotation_position="top left")
    st.plotly_chart(fig, use_container_width=True)

def plot_var_cvar_over_time(portfolio_returns, var, cvar):
    cumulative_returns = (1 + portfolio_returns).cumprod()
    fig = px.line(cumulative_returns, title='Cumulative Returns with VaR and CVaR Over Time', labels={'value': 'Cumulative Returns', 'index': 'Date'})
    fig.add_hline(y=var, line_dash="dash", line_color="red", annotation_text=f"VaR: {var:.2f}", annotation_position="bottom right")
    fig.add_hline(y=cvar, line_dash="dash", line_color="blue", annotation_text=f"CVaR: {cvar:.2f}", annotation_position="bottom right")
    st.plotly_chart(fig, use_container_width=True)

def calculate_var(returns, confidence_level=0.95):
    return np.percentile(returns, 100 * (1 - confidence_level))

def calculate_cvar(returns, confidence_level=0.95):
    var = calculate_var(returns, confidence_level)
    return returns[returns <= var].mean()

# ----------------------------
# Caching Functions
# ----------------------------
@st.cache_data(show_spinner=False)
def calculate_portfolio_metrics(returns, cum_returns, rf, benchmark_returns=None):
    metrics = calculate_simple_metrics(returns, cum_returns)

    if benchmark_returns is not None and not benchmark_returns.empty:
        correlation = returns.corr(benchmark_returns)
        beta = calculate_beta(returns, benchmark_returns)
        metrics['Benchmark Correlation'] = f"{correlation:.2f}" if not np.isnan(correlation) else "N/A"
        metrics['Beta'] = f"{beta:.2f}" if not np.isnan(beta) else "N/A"

        alpha = calculate_alpha(returns, benchmark_returns, rf)
        metrics['Alpha (annualized)'] = f"{alpha * 100:.2f}%" if not np.isnan(alpha) else "N/A"

        r2 = calculate_r_squared(returns, benchmark_returns)
        metrics['R2'] = f"{r2 * 100:.2f}%" if not np.isnan(r2) else "N/A"

        treynor = calculate_treynor_ratio(returns, benchmark_returns, rf)
        metrics['Treynor Ratio'] = f"{treynor:.2f}" if not np.isnan(treynor) else "N/A"

        calmar = calculate_calmar_ratio(returns, cum_returns)
        metrics['Calmar Ratio'] = f"{calmar:.2f}" if not np.isnan(calmar) else "N/A"

        m2 = calculate_modigliani_miller(returns, benchmark_returns, rf)
        metrics['Modigliani–Modigliani Measure'] = f"{m2 * 100:.2f}%" if not np.isnan(m2) else "N/A"

        info_ratio = calculate_information_ratio(returns, benchmark_returns)
        metrics['Information Ratio'] = f"{info_ratio:.2f}" if not np.isnan(info_ratio) else "N/A"

        tracking_error = calculate_tracking_error(returns, benchmark_returns)
        metrics['Tracking Error'] = f"{tracking_error * 100:.2f}%" if not np.isnan(tracking_error) else "N/A"

        active_return = calculate_active_return(returns, benchmark_returns)
        metrics['Active Return'] = f"{active_return:.2f}%" if not np.isnan(active_return) else "N/A"

        upside_capture = calculate_capture_ratio(returns, benchmark_returns, upside=True)
        downside_capture = calculate_capture_ratio(returns, benchmark_returns, upside=False)
        metrics['Upside Capture Ratio'] = f"{upside_capture:.2f}%" if not np.isnan(upside_capture) else "N/A"
        metrics['Downside Capture Ratio'] = f"{downside_capture:.2f}%" if not np.isnan(downside_capture) else "N/A"
    else:
        metrics.update({
            'Benchmark Correlation': "N/A",
            'Beta': "N/A",
            'Alpha (annualized)': "N/A",
            'R2': "N/A",
            'Treynor Ratio': "N/A",
            'Calmar Ratio': "N/A",
            'Modigliani–Modigliani Measure': "N/A",
            'Information Ratio': "N/A",
            'Tracking Error': "N/A",
            'Active Return': "N/A",
            'Upside Capture Ratio': "N/A",
            'Downside Capture Ratio': "N/A"
        })

    return metrics

@st.cache_data(show_spinner=False)
def get_tickers():
    try:
        # Fetch S&P 500 companies with requests, bypassing SSL verification
        sp500_url = 'https://en.wikipedia.org/wiki/List_of_S%26P_500_companies'
        sp500_response = requests.get(sp500_url, verify=False)
        sp500_table = pd.read_html(sp500_response.text)[0]
        sp500 = sp500_table[['Symbol', 'Security']].rename(columns={'Symbol': 'Ticker', 'Security': 'Company Name'})
        sp500['Ticker'] = sp500['Ticker'].str.replace('.', '-', regex=False)

        # Fetch NASDAQ-100 companies with requests, bypassing SSL verification
        nasdaq100_url = 'https://en.wikipedia.org/wiki/NASDAQ-100'
        nasdaq100_response = requests.get(nasdaq100_url, verify=False)
        nasdaq100_tables = pd.read_html(nasdaq100_response.text)

        nasdaq100 = pd.DataFrame()
        for table in nasdaq100_tables:
            if 'Ticker' in table.columns and 'Company' in table.columns:
                nasdaq100 = table[['Ticker', 'Company']].rename(columns={'Ticker': 'Ticker', 'Company': 'Company Name'})
                nasdaq100['Ticker'] = nasdaq100['Ticker'].str.replace('.', '-', regex=False)
                break

        combined = pd.concat([sp500, nasdaq100], ignore_index=True)
        combined = combined.drop_duplicates(subset=['Ticker'])
        return combined.sort_values('Ticker').reset_index(drop=True)
    except Exception as e:
        st.error(f"Error fetching tickers: {e}")
        return pd.DataFrame(columns=['Ticker', 'Company Name'])

@st.cache_data(show_spinner=False)
def download_data(tickers, start, end, retries=3, backoff_factor=0.3):
    try:
        # Configure retry strategy for requests (used by yfinance internally)
        session = requests.Session()
        retry = Retry(
            total=retries,
            read=retries,
            connect=retries,
            backoff_factor=backoff_factor,
            status_forcelist=(500, 502, 504),
        )
        adapter = HTTPAdapter(max_retries=retry)
        session.mount('http://', adapter)
        session.mount('https://', adapter)

        # Attempt to download data
        data = yf.download(tickers, start=start, end=end, progress=False, session=session)['Adj Close']

        # Handle potential empty data
        if isinstance(data, pd.Series):
            data = data.to_frame()
        if data.empty:
            st.warning("No price data available for the selected portfolio. Please check the ticker symbols and date range.")
            return pd.DataFrame()
        if data.index.tz is not None:
            data.index = data.index.tz_localize(None)

        # Fill missing data
        data = data.fillna(method='ffill').fillna(method='bfill')
        if data.isnull().values.any():
            st.warning("Data contains missing values after filling. Some calculations may be affected.")
        return data
    except Exception as e:
        st.error(f"Error downloading data: {e}")
        return pd.DataFrame()

the code of the above is a bit too long, try refactor it and enhance it, while dont affect the features, output the full enhanced code to me