Untitled

 avatar
unknown
plain_text
3 days ago
16 kB
2
Indexable
import pandas as pd
import numpy as np
from dateutil.relativedelta import relativedelta
import math

def get_price_on_date(prices_df, target_date, ticker):
    """
    Returns the last available price on or before target_date for a given ticker.
    """
    df_filtered = prices_df[prices_df['Date'] <= target_date]
    if df_filtered.empty:
        return np.nan
    return df_filtered.iloc[-1][ticker]

def compute_momentum_for_asset(prices_df, current_date, ticker, lookback_months):
    """
    Computes three momentum signals for the given asset and lookback period.
    Returns:
      - total_return (price-based momentum)
      - price_minus_sma (distance from moving average)
      - risk_adjusted (risk-adjusted momentum)
    """
    start_date = current_date - relativedelta(months=lookback_months)
    price_start = get_price_on_date(prices_df, start_date, ticker)
    price_current = get_price_on_date(prices_df, current_date, ticker)
    
    if pd.isna(price_start) or pd.isna(price_current):
        return np.nan, np.nan, np.nan
    
    total_return = (price_current / price_start) - 1
    
    prices_window = prices_df[(prices_df['Date'] >= start_date) & (prices_df['Date'] <= current_date)][ticker]
    sma = prices_window.mean() if not prices_window.empty else np.nan
    price_minus_sma = (price_current / sma) - 1 if (not pd.isna(sma) and sma != 0) else np.nan
    
    numerator = np.log(price_current / price_start)
    prices_period = prices_df[(prices_df['Date'] >= start_date) & (prices_df['Date'] <= current_date)][ticker].sort_index()
    if len(prices_period) < 2:
        risk_adjusted = np.nan
    else:
        log_returns = np.log(prices_period / prices_period.shift(1)).dropna()
        denominator = log_returns.abs().sum()
        risk_adjusted = numerator / denominator if denominator != 0 else np.nan
    
    return total_return, price_minus_sma, risk_adjusted

def compute_aggregated_momentum(prices_df, current_date, ticker, lookback_periods):
    """
    Computes aggregated momentum for an asset over multiple lookback periods.
    Returns:
      - aggregated_score: the average of all momentum signals (across all lookbacks)
      - is_positive: True only if every individual signal is positive.
      - signals: list of individual signals (for inspection)
    """
    signals = []
    for lb in lookback_periods:
        tr, pma, ra = compute_momentum_for_asset(prices_df, current_date, ticker, lb)
        if pd.isna(tr) or pd.isna(pma) or pd.isna(ra):
            return None, None, None  # Incomplete data; skip asset.
        signals.extend([tr, pma, ra])
    agg_score = np.mean(signals)
    is_positive = all(x > 0 for x in signals)
    return agg_score, is_positive, signals

def backtest_momentum_strategy(prices_df, start_date, end_date, rebalance_frequency, lookback_periods, aum, top_n, risk_on_list, risk_off_list):
    """
    Backtests the long-only momentum strategy with updated selection rules.
    
    The final portfolio always has exactly top_n (6) positions.
    Selection logic:
      - If ≥ 6 risk‑on assets have positive momentum: take top 6 positive.
      - If exactly 5 positive: add 1 risk‑off.
      - If exactly 4 positive: add 2 risk‑off.
      - If < 4 positive: take all positive, add both risk‑off, then fill remaining slots with the top-ranked risk‑on assets regardless of sign.
    
    Parameters:
      prices_df: DataFrame with a "Date" column (datetime) and asset price columns.
      start_date: Start date of the strategy (e.g. "2024-01-01")
      end_date: End date of the strategy (e.g. "2025-01-01")
      rebalance_frequency: Frequency string for rebalancing (e.g. "MS" for month start)
      lookback_periods: List of lookback periods in months (e.g. [3, 6, 9])
      aum: Starting assets under management
      top_n: Total number of positions to hold (e.g. 6)
      risk_on_list: List of risk-on asset tickers.
      risk_off_list: List of risk-off asset tickers.
      
    Returns:
      result_df: DataFrame with each rebalance date, portfolio AUM, and details of positions.
    """
    prices_df['Date'] = pd.to_datetime(prices_df['Date'])
    prices_df.sort_values('Date', inplace=True)
    
    # Build rebalancing dates
    rebalance_dates = pd.date_range(start=start_date, end=end_date, freq=rebalance_frequency)
    current_aum = aum
    result_records = []
    current_portfolio = {}  # {ticker: (quantity, entry_price)}
    
    for i, reb_date in enumerate(rebalance_dates):
        # Update portfolio AUM based on current prices (mark-to-market)
        if i > 0 and current_portfolio:
            portfolio_value = 0
            for ticker, (qty, entry_price) in current_portfolio.items():
                price_today = get_price_on_date(prices_df, reb_date, ticker)
                portfolio_value += qty * price_today
            current_aum = portfolio_value
        
        # Compute momentum scores for all risk-on assets
        risk_on_all = []
        for ticker in risk_on_list:
            agg_score, is_positive, _ = compute_aggregated_momentum(prices_df, reb_date, ticker, lookback_periods)
            if agg_score is not None:
                risk_on_all.append((ticker, agg_score, is_positive))
        risk_on_all = sorted(risk_on_all, key=lambda x: x[1], reverse=True)
        
        # Separate those with strictly positive momentum
        positive_risk_on = [ticker for ticker, score, is_positive in risk_on_all if is_positive]
        
        # Build final selection to always have exactly top_n (6) assets
        if len(positive_risk_on) >= 6:
            final_selection = positive_risk_on[:6]
        elif len(positive_risk_on) == 5:
            final_selection = positive_risk_on + risk_off_list[:1]
        elif len(positive_risk_on) == 4:
            final_selection = positive_risk_on + risk_off_list[:2]
        else:
            final_selection = positive_risk_on + risk_off_list[:2]
            for ticker, score, is_positive in risk_on_all:
                if ticker not in final_selection:
                    final_selection.append(ticker)
                if len(final_selection) == 6:
                    break
        
        final_selection = final_selection[:6]
        
        # Allocate equal weight among the selected assets
        allocation = current_aum / len(final_selection) if final_selection else 0
        positions = {}
        for ticker in final_selection:
            price_at_entry = get_price_on_date(prices_df, reb_date, ticker)
            qty = allocation / price_at_entry if price_at_entry != 0 else 0
            positions[ticker] = (qty, price_at_entry)
        
        record = {
            'Rebalance Date': reb_date,
            'Final AUM': current_aum,
            'Selected Assets': final_selection,
            'Quantities': [positions[ticker][0] for ticker in final_selection],
            'Entry Prices': [positions[ticker][1] for ticker in final_selection]
        }
        result_records.append(record)
        current_portfolio = positions.copy()
    
    result_df = pd.DataFrame(result_records)
    return result_df

def backtest_momentum_strategy_scores_approach(prices_df, start_date, end_date, rebalance_frequency, lookback_periods, aum, top_n, risk_on_list, risk_off_list):
    """
    Backtests the momentum strategy using the scores approach.
    
    At each rebalance date:
      - For each risk‑on asset, compute all momentum signals (total_return, price_minus_sma, risk_adjusted)
        over each lookback period.
      - Rank the risk‑on assets for each signal (highest value gets the highest rank).
      - Sum the rank scores across signals to obtain an aggregated score.
      - Select the top_n risk‑on assets based on the aggregated score.
      - For any selected asset that does not have all positive momentum signals,
        substitute it with the risk‑off asset with the highest aggregated score.
      - Allocate equal weight to each asset.
    """
    prices_df['Date'] = pd.to_datetime(prices_df['Date'])
    prices_df.sort_values('Date', inplace=True)
    
    rebalance_dates = pd.date_range(start=start_date, end=end_date, freq=rebalance_frequency)
    current_aum = aum
    result_records = []
    current_portfolio = {}
    
    for i, reb_date in enumerate(rebalance_dates):
        # Update AUM (mark-to-market) on rebalancing dates after the first.
        if i > 0 and current_portfolio:
            portfolio_value = 0
            for ticker, (qty, _) in current_portfolio.items():
                price_today = get_price_on_date(prices_df, reb_date, ticker)
                portfolio_value += qty * price_today
            current_aum = portfolio_value
        
        # ---- Build risk-on momentum signals table ----
        signal_data = {}
        is_positive_flag = {}
        for ticker in risk_on_list:
            signals = {}
            positive_checks = []
            for lb in lookback_periods:
                tr, pma, ra = compute_momentum_for_asset(prices_df, reb_date, ticker, lb)
                if pd.isna(tr) or pd.isna(pma) or pd.isna(ra):
                    signals[f"tr_{lb}"] = np.nan
                    signals[f"pma_{lb}"] = np.nan
                    signals[f"ra_{lb}"] = np.nan
                    positive_checks.append(False)
                else:
                    signals[f"tr_{lb}"] = tr
                    signals[f"pma_{lb}"] = pma
                    signals[f"ra_{lb}"] = ra
                    positive_checks.append(tr > 0 and pma > 0 and ra > 0)
            signal_data[ticker] = signals
            is_positive_flag[ticker] = all(positive_checks)
        
        risk_on_df = pd.DataFrame.from_dict(signal_data, orient='index')
        risk_on_df = risk_on_df.dropna()  # Drop assets with incomplete data
        
        if risk_on_df.empty:
            final_selection = []
        else:
            # ---- Ranking: For each signal, rank assets in descending order.
            ranking = risk_on_df.rank(method='min', ascending=False)
            # Convert ranks to scores: best gets highest (score = number of assets - rank + 1)
            ranking_scores = risk_on_df.shape[0] - ranking + 1
            # Aggregate the score across all signals.
            risk_on_df['agg_score'] = ranking_scores.sum(axis=1)
            # Add the is_positive flag for each asset.
            risk_on_df['is_positive'] = risk_on_df.index.map(lambda x: is_positive_flag.get(x, False))
            # Sort assets by aggregated score (higher is better).
            risk_on_df_sorted = risk_on_df.sort_values('agg_score', ascending=False)
            
            # Select top_n risk-on assets.
            final_selection = list(risk_on_df_sorted.index[:top_n])
            
            # ---- Substitute any asset with negative momentum ----
            # For any asset in the selection that does not have all positive signals,
            # compute risk-off scores and replace it with the best risk-off candidate not already selected.
            signal_data_off = {}
            is_positive_off = {}
            for ticker in risk_off_list:
                signals = {}
                positive_checks = []
                for lb in lookback_periods:
                    tr, pma, ra = compute_momentum_for_asset(prices_df, reb_date, ticker, lb)
                    if pd.isna(tr) or pd.isna(pma) or pd.isna(ra):
                        signals[f"tr_{lb}"] = np.nan
                        signals[f"pma_{lb}"] = np.nan
                        signals[f"ra_{lb}"] = np.nan
                        positive_checks.append(False)
                    else:
                        signals[f"tr_{lb}"] = tr
                        signals[f"pma_{lb}"] = pma
                        signals[f"ra_{lb}"] = ra
                        positive_checks.append(tr > 0 and pma > 0 and ra > 0)
                signal_data_off[ticker] = signals
                is_positive_off[ticker] = all(positive_checks)
            risk_off_df = pd.DataFrame.from_dict(signal_data_off, orient='index')
            risk_off_df = risk_off_df.dropna()
            if not risk_off_df.empty:
                ranking_off = risk_off_df.rank(method='min', ascending=False)
                ranking_scores_off = risk_off_df.shape[0] - ranking_off + 1
                risk_off_df['agg_score'] = ranking_scores_off.sum(axis=1)
                risk_off_df['is_positive'] = risk_off_df.index.map(lambda x: is_positive_off.get(x, False))
                risk_off_df_sorted = risk_off_df.sort_values('agg_score', ascending=False)
                
                # Replace each non-positive risk-on asset with the best risk-off candidate not already selected.
                final_selection_new = []
                risk_off_candidates = list(risk_off_df_sorted.index)
                for asset in final_selection:
                    if risk_on_df.loc[asset, 'is_positive']:
                        final_selection_new.append(asset)
                    else:
                        substitute = None
                        for candidate in risk_off_candidates:
                            if candidate not in final_selection_new and candidate not in final_selection:
                                substitute = candidate
                                break
                        final_selection_new.append(substitute if substitute else asset)
                final_selection = final_selection_new
            
            # In case fewer than top_n are available, fill remaining slots from risk-on (by aggregated score).
            if len(final_selection) < top_n:
                additional = list(risk_on_df_sorted.index.difference(final_selection))
                final_selection += additional[:(top_n - len(final_selection))]
            final_selection = final_selection[:top_n]
        
        # ---- Portfolio Allocation: Equal weighting ----
        allocation = current_aum / len(final_selection) if final_selection else 0
        positions = {}
        for ticker in final_selection:
            price_at_entry = get_price_on_date(prices_df, reb_date, ticker)
            qty = allocation / price_at_entry if price_at_entry != 0 else 0
            positions[ticker] = (qty, price_at_entry)
        
        record = {
            'Rebalance Date': reb_date,
            'Final AUM': current_aum,
            'Selected Assets': final_selection,
            'Quantities': [positions[ticker][0] for ticker in final_selection],
            'Entry Prices': [positions[ticker][1] for ticker in final_selection]
        }
        result_records.append(record)
        current_portfolio = positions.copy()
    
    result_df = pd.DataFrame(result_records)
    return result_df

# =============================================================================
# Example usage:
# =============================================================================
# Assume that 'prices' is a DataFrame with a "Date" column and asset price columns.
# For example, the columns might be: ['Date', 'Asset1', 'Asset2', ... 'Asset15']
#
# Define risk_on_list and risk_off_list based on the columns in your 'prices' DataFrame.
# Here we assume columns 1 to 13 (index positions 1 to 13) are risk-on assets,
# and columns 14 to 15 are risk-off assets.
risk_on_list = list(prices.columns[1:14])
risk_off_list = list(prices.columns[14:16])

# Using the original backtest function:
result_df_original = backtest_momentum_strategy(
    prices_df=prices, 
    start_date="2024-01-01", 
    end_date="2025-01-01",
    rebalance_frequency="MS", 
    lookback_periods=[3, 6, 9],
    aum=1000000, 
    top_n=6,
    risk_on_list=risk_on_list,
    risk_off_list=risk_off_list
)

# Using the scores approach backtest function:
result_df_scores = backtest_momentum_strategy_scores_approach(
    prices_df=prices, 
    start_date="2024-01-01", 
    end_date="2025-01-01",
    rebalance_frequency="MS", 
    lookback_periods=[3, 6, 9],
    aum=1000000, 
    top_n=6,
    risk_on_list=risk_on_list,
    risk_off_list=risk_off_list
)

# Output the results for inspection.
print("Original Backtest Strategy Results:")
print(result_df_original)

print("\nScores Approach Backtest Strategy Results:")
print(result_df_scores)
Editor is loading...
Leave a Comment