Key Metrics
Annualized Return (CAGR)

-74.17040403237051
Sharpe Ratio

0.5996694501199279
Maximum Drawdown

-61.326630028689465

Overview


Performance Statistics


Advanced Metrics


Drawdowns


Visualizations

📊 Performance Statistics
 	Portfolio	Benchmark
Start Balance	100.0	100.0

I want 2 decimal points only and fix the above bug, return full code to me:
# metrics.py
import numpy as np
import pandas as pd
import statsmodels.api as sm
import streamlit as st
from scipy.stats import skew, kurtosis

def calculate_sharpe_ratio(returns, rf=0.02):
    """
    Calculate the Sharpe Ratio for a given set of returns.
    """
    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):
    """
    Calculate the Sortino Ratio for a given set of returns.
    """
    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_beta(returns, benchmark_returns):
    """
    Calculate the Beta of the portfolio relative to the benchmark.
    """
    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):
    """
    Calculate the Alpha of the portfolio relative to the benchmark.
    """
    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_treynor_ratio(returns, benchmark_returns, rf=0.02):
    """
    Calculate the Treynor Ratio for a given set of returns.
    """
    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):
    """
    Calculate the Calmar Ratio for a given set of 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_r_squared(returns, benchmark_returns):
    """
    Calculate the R-squared of the portfolio relative to the benchmark.
    """
    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):
    """
    Calculate the Information Ratio of the portfolio relative to the benchmark.
    """
    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 calculate_tracking_error(returns, benchmark_returns):
    """
    Calculate the Tracking Error of the portfolio relative to the benchmark.
    """
    if returns.empty or benchmark_returns.empty:
        return np.nan
    return np.std((returns - benchmark_returns)) * np.sqrt(252)

def calculate_active_return(returns, benchmark_returns):
    """
    Calculate the Active Return of the portfolio relative to the benchmark.
    """
    if returns.empty or benchmark_returns.empty:
        return np.nan
    return (returns.mean() - benchmark_returns.mean()) * 252 * 100

def calculate_gain_loss_ratio(returns):
    """
    Calculate the Gain/Loss Ratio of the portfolio.
    """
    gains = returns[returns > 0].sum()
    losses = -returns[returns < 0].sum()
    return gains / losses if losses != 0 else np.nan

def drawdown(cum_returns):
    """
    Calculate the Maximum Drawdown of the portfolio.
    """
    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):
    """
    Calculate the Upside or Downside Capture Ratio of the portfolio.
    """
    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):
    """
    Calculate the Safe Withdrawal Rate based on portfolio returns.
    """
    if returns.empty:
        return np.nan
    return (returns.mean() / returns.std()) * 100

def calculate_perpetual_withdrawal_rate(returns):
    """
    Calculate the Perpetual Withdrawal Rate based on portfolio returns.
    """
    if returns.empty:
        return np.nan
    return (returns.mean() / returns.std()) * 100

def calculate_positive_periods(returns):
    """
    Calculate the number and percentage of positive return periods.
    """
    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):
    """
    Calculate the Modigliani–Modigliani Measure (M²) of the portfolio.
    """
    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 calculate_beta_local(portfolio_returns, benchmark_returns):
    """
    Local version of calculate_beta to avoid naming conflicts.
    """
    if portfolio_returns.empty or benchmark_returns.empty:
        return np.nan
    covariance_matrix = np.cov(portfolio_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_var(returns, confidence_level=0.95):
    """
    Calculate the Value at Risk (VaR) for a given set of returns.
    """
    return np.percentile(returns, 100 * (1 - confidence_level))

def calculate_cvar(returns, confidence_level=0.95):
    """
    Calculate the Conditional Value at Risk (CVaR) for a given set of returns.
    """
    var = calculate_var(returns, confidence_level)
    return returns[returns <= var].mean()

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}%" if not returns.empty else np.nan
            metrics['Arithmetic Mean (Annualized)'] = f"{returns.mean() * 252 * 100:.2f}%" if not returns.empty else np.nan
            metrics['Geometric Mean (Monthly)'] = f"{(np.exp(np.log1p(returns).mean()) - 1) * 100:.2f}%" if not returns.empty else np.nan
            metrics['Geometric Mean (Annualized)'] = f"{(np.exp(np.log1p(returns).mean() * 252) - 1) * 100:.2f}%" if not returns.empty else np.nan
            metrics['Standard Deviation (Monthly)'] = f"{returns.std() * 100:.2f}%" if not returns.empty else np.nan
            metrics['Standard Deviation (Annualized)'] = f"{returns.std() * np.sqrt(252) * 100:.2f}%" if not returns.empty else np.nan
            metrics['Downside Deviation (Monthly)'] = f"{returns[returns < 0].std() * 100:.2f}%" if not returns.empty else np.nan
            metrics['Maximum Drawdown'] = f"{drawdown(cum_returns) * 100:.2f}%" if not cum_returns.empty else np.nan
            metrics['Sharpe Ratio'] = f"{calculate_sharpe_ratio(returns, 0.02):.2f}" if not cum_returns.empty else np.nan
            metrics['Sortino Ratio'] = f"{calculate_sortino_ratio(returns, 0.02):.2f}" if not cum_returns.empty else np.nan
            metrics['Gain/Loss Ratio'] = f"{calculate_gain_loss_ratio(returns):.2f}" if not cum_returns.empty else np.nan
            metrics['Skewness'] = f"{skew(returns):.2f}" if not returns.empty else np.nan
            metrics['Excess Kurtosis'] = f"{kurtosis(returns):.2f}" if not returns.empty else np.nan
            metrics['Safe Withdrawal Rate'] = f"{calculate_safe_withdrawal_rate(returns):.6f}%" if not returns.empty else np.nan
            metrics['Perpetual Withdrawal Rate'] = f"{calculate_perpetual_withdrawal_rate(returns):.6f}%" if not returns.empty else np.nan
            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] = np.nan  # Use NaN instead of "N/A"
    else:
        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] = np.nan  # Use NaN instead of "N/A"
    
    return metrics

def calculate_portfolio_metrics(returns, cum_returns, rf_rate=0.02, benchmark_returns=None):
    """
    Calculate various portfolio metrics.
    
    Parameters:
    - returns (pd.Series or pd.DataFrame): Portfolio's daily returns.
    - cum_returns (pd.Series): Cumulative returns of the portfolio.
    - rf_rate (float): Risk-free rate.
    - benchmark_returns (pd.Series or pd.DataFrame, optional): Benchmark's daily returns.
    
    Returns:
    - dict: Calculated metrics.
    """
    metrics = {}
    try:
        metrics['Start Balance'] = 100.0  # Assuming starting with 100
        metrics['End Balance'] = cum_returns.iloc[-1]
        
        # Calculate the number of periods and years
        num_days = (cum_returns.index[-1] - cum_returns.index[0]).days
        years = num_days / 365.25
        
        # Annualized Return (CAGR)
        metrics['Annualized Return (CAGR)'] = ((metrics['End Balance']/metrics['Start Balance']) ** (1 / years) - 1) * 100 if years > 0 else np.nan
        
        # Standard Deviation (Annualized)
        metrics['Standard Deviation (Annualized)'] = returns.std() * np.sqrt(252) * 100 if not returns.empty else np.nan
        
        # Best Year
        yearly_returns = returns.resample('Y').apply(lambda x: (1 + x).prod() - 1)
        metrics['Best Year'] = yearly_returns.max() * 100 if not yearly_returns.empty else np.nan
        
        # Worst Year
        metrics['Worst Year'] = yearly_returns.min() * 100 if not yearly_returns.empty else np.nan
        
        # Maximum Drawdown
        metrics['Maximum Drawdown'] = (cum_returns / cum_returns.cummax() - 1).min() * 100 if not cum_returns.empty else np.nan
        
        # Sharpe Ratio
        portfolio_return = returns.mean() * 252
        portfolio_volatility = returns.std() * np.sqrt(252)
        metrics['Sharpe Ratio'] = (portfolio_return - rf_rate) / portfolio_volatility if portfolio_volatility != 0 else np.nan
        
        # Sortino Ratio
        downside_std = returns[returns < 0].std() * np.sqrt(252)
        metrics['Sortino Ratio'] = (portfolio_return - rf_rate) / downside_std if downside_std != 0 else np.nan
        
        # Gain/Loss Ratio
        metrics['Gain/Loss Ratio'] = calculate_gain_loss_ratio(returns)
        
        # Skewness
        metrics['Skewness'] = skew(returns) if not returns.empty else np.nan
        
        # Excess Kurtosis
        metrics['Excess Kurtosis'] = kurtosis(returns) if not returns.empty else np.nan
        
        # Safe Withdrawal Rate
        metrics['Safe Withdrawal Rate'] = calculate_safe_withdrawal_rate(returns) if not returns.empty else np.nan
        
        # Perpetual Withdrawal Rate
        metrics['Perpetual Withdrawal Rate'] = calculate_perpetual_withdrawal_rate(returns) if not returns.empty else np.nan
        
        # Positive Periods
        metrics['Positive Periods'] = calculate_positive_periods(returns) if not returns.empty else "N/A"
        
        # Information Ratio
        if benchmark_returns is not None and not benchmark_returns.empty:
            active_returns = returns - benchmark_returns
            tracking_error = calculate_tracking_error(returns, benchmark_returns)
            metrics['Information Ratio'] = (active_returns.mean() * 252) / tracking_error if tracking_error != 0 else np.nan
        else:
            metrics['Information Ratio'] = np.nan
        
        # Calmar Ratio
        metrics['Calmar Ratio'] = metrics['Annualized Return (CAGR)'] / abs(metrics['Maximum Drawdown']) if abs(metrics['Maximum Drawdown']) != 0 else np.nan
        
        # Treynor Ratio
        if benchmark_returns is not None and not benchmark_returns.empty:
            beta = calculate_beta(returns, benchmark_returns)
            metrics['Treynor Ratio'] = (portfolio_return - rf_rate) / beta if beta != 0 else np.nan
        else:
            metrics['Treynor Ratio'] = np.nan
        
        # Alpha (annualized)
        if benchmark_returns is not None and not benchmark_returns.empty:
            alpha = calculate_alpha(returns, benchmark_returns, rf_rate)
            metrics['Alpha (annualized)'] = alpha * 100 if not np.isnan(alpha) else np.nan
        else:
            metrics['Alpha (annualized)'] = np.nan
        
        # Modigliani–Modigliani Measure
        metrics['Modigliani–Modigliani Measure'] = metrics['Annualized Return (CAGR)'] / metrics['Sharpe Ratio'] if metrics['Sharpe Ratio'] != 0 else np.nan
        
        # Upside Capture Ratio
        if benchmark_returns is not None and not benchmark_returns.empty:
            metrics['Upside Capture Ratio'] = calculate_capture_ratio(returns, benchmark_returns, upside=True)
        else:
            metrics['Upside Capture Ratio'] = np.nan
        
        # Benchmark Correlation, Beta, R-squared
        if benchmark_returns is not None and not benchmark_returns.empty:
            correlation = returns.corr(benchmark_returns)
            beta = calculate_beta(returns, benchmark_returns)
            r_squared = calculate_r_squared(returns, benchmark_returns)
            metrics['Benchmark Correlation'] = correlation if not np.isnan(correlation) else np.nan
            metrics['Beta'] = beta if not np.isnan(beta) else np.nan
            metrics['R2'] = r_squared if not np.isnan(r_squared) else np.nan
        else:
            metrics['Benchmark Correlation'] = np.nan
            metrics['Beta'] = np.nan
            metrics['R2'] = np.nan
        
        return metrics
    except Exception as e:
        st.error(f"Error calculating metrics: {e}")
        # Assign NaN or "N/A" to all expected metrics to prevent KeyError
        expected_keys = [
            'End Balance', 'Annualized Return (CAGR)', 'Standard Deviation (Annualized)',
            'Best Year', 'Worst Year', 'Maximum Drawdown', 'Sharpe Ratio',
            'Sortino Ratio', 'Gain/Loss Ratio', 'Skewness', 'Excess Kurtosis',
            'Safe Withdrawal Rate', 'Perpetual Withdrawal Rate', 'Positive Periods',
            'Information Ratio', 'Calmar Ratio', 'Treynor Ratio', 'Alpha (annualized)',
            'Modigliani–Modigliani Measure', 'Upside Capture Ratio',
            'Benchmark Correlation', 'Beta', 'R2'
        ]
        for key in expected_keys:
            metrics[key] = np.nan
        return metrics
        
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.

    This function combines robust data alignment, regression analysis, contribution
    calculation based on both variance and returns, and detailed commentary on each
    factor's contribution to the portfolio's performance.

    Parameters:
    - returns (pd.Series): Portfolio returns.
    - factors (pd.DataFrame): DataFrame where each column represents a risk factor.
                             Must include 'RF' (Risk-Free Rate) if available.
    - annualization_factor (int): Annualization factor (252 for daily, 12 for monthly returns).

    Returns:
    - pd.DataFrame: 
        - 'Factor': Name of the risk factor.
        - 'Contribution (%)': Percentage contribution of the factor to the portfolio's return.
        - 'Comment': Detailed commentary on the factor's contribution.
    - str or None: Error message if any issues occur, else None.
    """
    try:
        # Ensure returns have 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'].abs().max() > 1:
            returns = returns / 100

        if factors.abs().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['Excess_Returns'] = returns['Portfolio_Returns'] - factors['RF']
            factors = factors.drop(columns=['RF'])
        else:
            returns['Excess_Returns'] = returns['Portfolio_Returns']

        # 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['Excess_Returns']

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

        # Calculate factor contributions based on mean returns
        factor_means = X.mean().drop('const', errors='ignore')
        contributions = coefficients * factor_means

        # Calculate total excess return
        total_return = y.mean()

        # Check if total_return is zero to avoid division by zero
        if np.isclose(total_return, 0):
            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
        attribution_df = attribution_df[attribution_df['Factor'] != 'const']

        # Optionally, calculate variance-based contributions
        # Uncomment the following lines if variance-based attribution is desired
        """
        portfolio_variance = y.var() * annualization_factor
        factor_variances = X.var().drop('const', errors='ignore') * annualization_factor
        variance_contributions = (coefficients ** 2) * factor_variances
        variance_contribution_percentage = (variance_contributions / portfolio_variance) * 100
        attribution_df_variance = pd.DataFrame({
            'Factor': variance_contribution_percentage.index,
            'Variance Contribution (%)': variance_contribution_percentage.round(2)
        })
        """

        # Replace factor abbreviations with full names
        factor_full_names = {
            'Mkt-RF': 'Market Risk Premium',
            'SMB': 'Small Minus Big',
            'HML': 'High Minus Low',
            'UMD': 'Up Minus Down',  # Add more mappings as needed
            # Add other factor mappings here
        }
        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 (e.g., 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 more factor-specific analyses as needed
            else:
                if contribution > 0:
                    comments.append(f"{factor_name} = {contribution}%: Positive contribution to portfolio returns.")
                elif contribution < 0:
                    comments.append(f"{factor_name} = {contribution}%: Negative contribution to portfolio returns.")
                else:
                    comments.append(f"{factor_name} = {contribution}%: No significant contribution.")

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

        # Optional: If variance-based attribution is calculated
        # Uncomment the following lines to include variance contributions
        """
        attribution_df = attribution_df.merge(attribution_df_variance, on='Factor', how='left')
        """

        return attribution_df, None

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

def calculate_performance_attribution(returns, weights):
    """
    Calculate the performance attribution of the portfolio.

    Parameters:
    - returns (pd.DataFrame or pd.Series): Historical returns of assets.
    - weights (list or np.ndarray): Weights of each asset in the portfolio.

    Returns:
    - pd.DataFrame: Asset names, their contribution percentages, and explanatory comments.
    """
    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_final_score(primary_metrics, comparison_metrics, global_skipped=None):
    """
    Calculate the final score for a portfolio based on various metrics compared to a benchmark.

    Parameters:
    - primary_metrics (dict): Metrics of the primary portfolio.
    - comparison_metrics (dict): Metrics of the comparison (benchmark) portfolio.
    - global_skipped (set, optional): A set to accumulate skipped metrics due to missing data or errors.

    Returns:
    - float: Final score out of 100.
    """
    if global_skipped is None:
        global_skipped = set()
    
    # Define metrics and their corresponding weights
    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',
        'Maximum Drawdown'  # Added Maximum Drawdown to handle lower is better
    ]
    
    metric_weights = {
        'Annualized Return (CAGR)': 15,
        'Sharpe Ratio': 15,
        'Sortino Ratio': 10,
        'Treynor Ratio': 10,
        'Calmar Ratio': 10,
        'Alpha (annualized)': 10,
        'Information Ratio': 5,
        'Modigliani–Modigliani Measure': 5,
        'Upside Capture Ratio': 5,
        'Gain/Loss Ratio': 5,
        'Maximum Drawdown': 10  # Assign a weight for Maximum Drawdown
    }

    # Ensure metric weights sum to 100
    total_assigned_weight = sum(metric_weights.values())
    if not np.isclose(total_assigned_weight, 100):
        raise ValueError(
            f"Total metric weights must sum to 100, but got {total_assigned_weight}. "
            "Please adjust the weights accordingly."
        )

    # Define which metrics are better when higher or lower
    higher_is_better_metrics = {
        'Annualized Return (CAGR)',
        'Sharpe Ratio',
        'Sortino Ratio',
        'Treynor Ratio',
        'Calmar Ratio',
        'Alpha (annualized)',
        'Information Ratio',
        'Modigliani–Modigliani Measure',
        'Upside Capture Ratio',
        'Gain/Loss Ratio'
    }
    
    lower_is_better_metrics = {
        'Maximum Drawdown'
    }

    score = 0.0
    total_weight = 0.0
    skipped_metrics = set()

    for metric in metrics_to_compare:
        primary = primary_metrics.get(metric, "N/A")
        comparison = comparison_metrics.get(metric, "N/A")
        
        if primary == "N/A" or comparison == "N/A":
            skipped_metrics.add(metric)
            continue
        
        try:
            # Convert metrics to float, handling percentage strings
            def convert_to_float(value):
                if isinstance(value, str) and '%' in value:
                    return float(value.strip('%'))
                return float(value)
            
            primary_val = convert_to_float(primary)
            comparison_val = convert_to_float(comparison)
            
            # Check for finite numbers
            if not (np.isfinite(primary_val) and np.isfinite(comparison_val)):
                skipped_metrics.add(metric)
                continue
            
            weight = metric_weights.get(metric, 0)
            
            if metric in higher_is_better_metrics:
                # Avoid division by zero
                if comparison_val == 0:
                    skipped_metrics.add(metric)
                    continue
                
                ratio = primary_val / comparison_val
                ratio = min(max(ratio, 0), 2)  # Cap ratio between 0 and 2
                
                # Full credit if primary >= comparison, partial otherwise
                if primary_val >= comparison_val:
                    metric_score = weight * ratio
                else:
                    metric_score = weight * ratio * 0.5  # Partial credit
                    
            elif metric in lower_is_better_metrics:
                # For lower is better, invert the ratio
                if primary_val == 0:
                    skipped_metrics.add(metric)
                    continue
                ratio = comparison_val / primary_val
                ratio = min(max(ratio, 0), 2)  # Cap ratio between 0 and 2
                
                # Full credit if primary <= comparison, partial otherwise
                if primary_val <= comparison_val:
                    metric_score = weight * ratio
                else:
                    metric_score = weight * ratio * 0.5  # Partial credit
            else:
                # If metric type is unknown, skip it
                skipped_metrics.add(metric)
                continue
            
            score += metric_score
            total_weight += weight
        
        except (ValueError, AttributeError):
            # Skip metric if conversion fails
            skipped_metrics.add(metric)
            continue

    # Update global skipped metrics
    if global_skipped is not None:
        global_skipped.update(skipped_metrics)

    # Calculate final score
    if total_weight > 0 and np.isfinite(score):
        final_score = (score / total_weight) * 100
    else:
        final_score = 0.0

    # Ensure the final score is within 0 to 100
    final_score = min(max(final_score, 0), 100)

    return final_score

def get_drawdown_details(cum_returns):
    """
    Calculate detailed drawdown information for the portfolio.

    Parameters:
    - cum_returns (pd.Series): Cumulative returns of the portfolio.

    Returns:
    - list: Detailed drawdown information.
    """
    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