Untitled

 avatar
unknown
plain_text
2 months ago
14 kB
5
Indexable
import pandas as pd
import numpy as np
from datetime import datetime
from dateutil.relativedelta import relativedelta

###############################################
# 1. Data Loading
###############################################

def load_price_data(filepath):
    """
    Load historical prices from an Excel file.
    Assumes that the first column is dates and the remaining columns are tickers.
    """
    df = pd.read_excel(filepath, index_col=0)
    df.index = pd.to_datetime(df.index)
    return df

###############################################
# 2. Helper: Observation Dates
###############################################

def get_observation_dates(start_date, end_date, rebalance_period):
    """
    Returns a list of observation dates from start_date to end_date
    with a step equal to rebalance_period (in months).
    """
    dates = []
    current = start_date
    while current <= end_date:
        dates.append(current)
        current += relativedelta(months=rebalance_period)
    return dates

###############################################
# 3. Initialize the Portfolio
###############################################

def initialize_portfolio(prices, date, tickers, initial_aum):
    """
    On the start date, invest equal notional amounts in each asset.
    Returns a dictionary mapping ticker -> quantity.
    """
    portfolio = {}
    allocation = initial_aum / len(tickers)
    for ticker in tickers:
        price = prices.loc[date, ticker]
        portfolio[ticker] = allocation / price
    return portfolio

###############################################
# 4. Lookback Metric Computation
###############################################

def compute_lookback_metric(prices, current_date, ticker, lookback_period, metric_type='simple'):
    """
    Computes the lookback metric for one ticker.
    
    If metric_type=='simple': metric = (Price_today / Price_lookback) - 1.
    If metric_type=='sma': metric = (Price_today - SMA) / SMA,
      where SMA is the simple moving average over the lookback period.
    
    If the exact lookback date is missing, we use the nearest available prior date.
    """
    # Define the date at which the lookback period starts.
    lookback_date = current_date - relativedelta(months=lookback_period)
    
    # Get current price (if date not in index, get most recent prior date)
    try:
        current_price = prices.loc[current_date, ticker]
    except KeyError:
        current_price = prices[ticker].loc[:current_date].iloc[-1]
    
    # Similarly for the lookback price:
    try:
        lookback_price = prices.loc[lookback_date, ticker]
    except KeyError:
        lookback_price = prices[ticker].loc[:lookback_date].iloc[-1]
    
    if metric_type == 'simple':
        metric = (current_price / lookback_price) - 1
    elif metric_type == 'sma':
        # Get all prices from lookback_date up to current_date
        window = prices[ticker].loc[lookback_date:current_date]
        sma = window.mean()
        metric = (current_price - sma) / sma
    else:
        raise ValueError("Invalid metric type. Choose 'simple' or 'sma'.")
    return metric

###############################################
# 5. Ranking Assets by Momentum
###############################################

def rank_assets(prices, current_date, tickers, lookback_period, metric_type):
    """
    For a given observation date, compute the chosen lookback metric for each asset,
    then sort (in descending order) so that the highest momentum gets rank 1.
    
    Returns:
      sorted_tickers: list of tickers in sorted order (best first)
      ranks: dictionary mapping ticker -> rank (1 is best)
      metrics: dictionary mapping ticker -> computed metric value
    """
    metrics = {}
    for ticker in tickers:
        metric = compute_lookback_metric(prices, current_date, ticker, lookback_period, metric_type)
        metrics[ticker] = metric
    sorted_tickers = sorted(metrics, key=metrics.get, reverse=True)
    ranks = {ticker: rank+1 for rank, ticker in enumerate(sorted_tickers)}
    return sorted_tickers, ranks, metrics

###############################################
# 6. Compute Current Portfolio Value
###############################################

def compute_portfolio_value(portfolio, prices, current_date):
    """
    Returns the portfolio AUM as of current_date.
    """
    value = 0
    for ticker, quantity in portfolio.items():
        price = prices.loc[current_date, ticker]
        value += quantity * price
    return value

###############################################
# 7. Rebalance the Portfolio
###############################################

def rebalance_portfolio(portfolio, prices, current_date, tickers, sorted_tickers,
                        internal_rebalance_ratios, rebalance_ratio):
    """
    On an observation date:
      (a) Compute the current portfolio value and the rebalancing notional (rebalance_ratio * AUM).
      (b) For each asset, assign a target trade notional = (rebalance_amount * internal ratio),
          where the internal ratio is picked according to the asset’s rank (from sorted_tickers).
          (For example, if internal_rebalance_ratios = [0.7, 0.2, 0.1, 0, 0, 0, 0, -0.1, -0.2, -0.7],
           then the best ranked asset gets +0.7×rebalance_amount, and the worst gets –0.7×rebalance_amount.)
      (c) For sell orders (negative target trades) check if the available notional (quantity × current price)
          is sufficient. If not, sell only what is available; the “shortfall” is then aggregated.
      (d) Redistribute the total shortfall among the buying orders (assets with positive target trades)
          proportionally.
      (e) Update each asset’s quantity accordingly.
    
    Returns the updated portfolio along with some diagnostics.
    """
    # 1. Compute current AUM and the total rebalance notional.
    portfolio_value = compute_portfolio_value(portfolio, prices, current_date)
    rebalance_amount = portfolio_value * rebalance_ratio
    
    # 2. Compute target trades for each asset according to the ranking.
    # Here we assume that len(sorted_tickers)==len(internal_rebalance_ratios)
    target_trades = {}
    for i, ticker in enumerate(sorted_tickers):
        target_trade = rebalance_amount * internal_rebalance_ratios[i]
        target_trades[ticker] = target_trade

    # 3. For sell orders: check available notional and determine shortfall.
    total_shortfall = 0
    actual_trades = {}
    for ticker, target_trade in target_trades.items():
        price = prices.loc[current_date, ticker]
        if target_trade < 0:
            available_notional = portfolio[ticker] * price
            sell_target = abs(target_trade)
            if available_notional < sell_target:
                # Not enough to sell – sell what you have and note the shortfall.
                actual_sell = available_notional
                shortfall = sell_target - available_notional
            else:
                actual_sell = sell_target
                shortfall = 0
            actual_trades[ticker] = -actual_sell   # negative = sale
            total_shortfall += shortfall
        else:
            # For buys, initially set to target; we’ll add any extra cash from the shortfall.
            actual_trades[ticker] = target_trade

    # 4. Redistribute the shortfall to the buying side.
    total_buy_target = sum(trade for ticker, trade in target_trades.items() if trade > 0)
    if total_buy_target > 0:
        for ticker in sorted_tickers:
            if target_trades[ticker] > 0:
                proportion = target_trades[ticker] / total_buy_target
                additional_buy = total_shortfall * proportion
                actual_trades[ticker] += additional_buy

    # 5. Update portfolio quantities.
    new_portfolio = portfolio.copy()
    for ticker, trade_notional in actual_trades.items():
        price = prices.loc[current_date, ticker]
        if trade_notional > 0:
            # Increase position: buy additional quantity.
            qty_change = trade_notional / price
            new_portfolio[ticker] += qty_change
        elif trade_notional < 0:
            # Decrease position: sell quantity (we already capped sales if necessary).
            qty_change = abs(trade_notional) / price
            new_portfolio[ticker] -= qty_change
        # Zero trade leaves the position unchanged.
    return new_portfolio, actual_trades, total_shortfall, portfolio_value

###############################################
# 8. Simulate the Strategy
###############################################

def simulate_strategy(prices, eq_tickers, fi_tickers, alts_tickers,
                      initial_aum, start_date, end_date,
                      rebalance_period, rebalance_ratio,
                      lookback_period, metric_type,
                      internal_rebalance_ratios):
    """
    Runs the simulation from start_date to end_date.
    
    At t0, we initialize the portfolio with equal weights.
    At each observation date, we compute lookback metrics, rank assets,
    then rebalance a fixed percentage (rebalance_ratio) of the current AUM.
    
    The results (portfolio AUM, individual quantities and notionals, returns, etc.)
    are recorded in a DataFrame.
    """
    # Combine tickers from the three arrays.
    tickers = eq_tickers + fi_tickers + alts_tickers
    
    # Determine observation dates.
    obs_dates = get_observation_dates(start_date, end_date, rebalance_period)
    
    # Prepare a list to collect results.
    results = []
    
    # 8a. Initial portfolio (at start_date)
    portfolio = initialize_portfolio(prices, start_date, tickers, initial_aum)
    portfolio_value = compute_portfolio_value(portfolio, prices, start_date)
    results.append({
        'Date': start_date,
        'Portfolio Value': portfolio_value,
        **{f'{ticker}_qty': portfolio[ticker] for ticker in tickers},
        **{f'{ticker}_notional': portfolio[ticker] * prices.loc[start_date, ticker] for ticker in tickers},
        'Return': 0
    })
    
    prev_value = portfolio_value
    
    # 8b. Loop over each observation date (after the start date).
    for current_date in obs_dates[1:]:
        # Compute momentum (lookback metric) and rank assets.
        sorted_tickers, ranks, metrics = rank_assets(prices, current_date, tickers,
                                                      lookback_period, metric_type)
        
        # Rebalance the portfolio.
        portfolio, trades, shortfall, pre_rebalance_value = rebalance_portfolio(
            portfolio, prices, current_date, tickers, sorted_tickers,
            internal_rebalance_ratios, rebalance_ratio)
        
        # Compute the new portfolio value and return.
        portfolio_value = compute_portfolio_value(portfolio, prices, current_date)
        ret = (portfolio_value - prev_value) / prev_value
        prev_value = portfolio_value
        
        # Record details.
        row = {
            'Date': current_date,
            'Portfolio Value': portfolio_value,
            'Return': ret,
            'Total Shortfall': shortfall,
            'Pre-Rebalance Value': pre_rebalance_value,
        }
        for ticker in tickers:
            row[f'{ticker}_qty'] = portfolio[ticker]
            row[f'{ticker}_notional'] = portfolio[ticker] * prices.loc[current_date, ticker]
            row[f'{ticker}_metric'] = metrics.get(ticker, np.nan)
            row[f'{ticker}_rank'] = ranks.get(ticker, np.nan)
            row[f'{ticker}_trade'] = trades.get(ticker, 0)
        results.append(row)
    
    result_df = pd.DataFrame(results)
    result_df.set_index('Date', inplace=True)
    return result_df

###############################################
# 9. Main – Example Usage
###############################################

if __name__ == '__main__':
    # Define the asset tickers.
    eq_tickers    = ['EQ1', 'EQ2', 'EQ3']
    fi_tickers    = ['FI1', 'FI2', 'FI3']
    alts_tickers  = ['ALTS1', 'ALTS2', 'ALTS3', 'ALTS4']
    
    # Define strategy parameters.
    initial_aum = 100e6              # e.g., 100 million
    start_date  = pd.to_datetime('2025-01-01')
    end_date    = pd.to_datetime('2025-12-31')
    rebalance_period = 1             # rebalance every month
    rebalance_ratio  = 0.10          # 10% of current AUM is rebalanced each period
    lookback_period  = 3             # 3-month lookback
    metric_type      = 'simple'      # use simple return metric; alternatively, set 'sma'
    
    # Define the internal rebalancing mapping.
    # For 10 assets (sorted best-to-worst), the intended trade proportions are:
    # Best asset: +70%, second: +20%, third: +10%,
    # then no trade for the middle four,
    # and the worst three: -10%, -20%, -70% (i.e. sell orders).
    internal_rebalance_ratios = [0.7, 0.2, 0.1, 0, 0, 0, 0, -0.1, -0.2, -0.7]
    
    # Specify the location of the Excel file.
    filepath = r"\\asiapac.nom\data\MUM\IWM\India_IWM_IPAS\Reet\Momentum Strategy\Codes\Historic Prices.xlsx"
    
    # Try loading the prices. If the file is not available, simulate some price data.
    try:
        prices = load_price_data(filepath)
    except Exception as e:
        print("Error loading Excel file; simulating price data. Exception:", e)
        dates = pd.date_range(start='2024-10-01', end='2025-12-31')
        tickers = eq_tickers + fi_tickers + alts_tickers
        data = {}
        np.random.seed(0)
        for ticker in tickers:
            # Simulate a random walk starting at 100.
            data[ticker] = 100 + np.cumsum(np.random.randn(len(dates)))
        prices = pd.DataFrame(data, index=dates)
    
    # Run the simulation.
    result_df = simulate_strategy(prices, eq_tickers, fi_tickers, alts_tickers,
                                  initial_aum, start_date, end_date,
                                  rebalance_period, rebalance_ratio,
                                  lookback_period, metric_type,
                                  internal_rebalance_ratios)
    
    # Display the final results.
    pd.set_option('display.float_format', lambda x: f'{x:,.2f}')
    print(result_df)
Editor is loading...
Leave a Comment