Untitled
unknown
plain_text
a month ago
29 kB
6
Indexable
import pandas as pd import numpy as np from datetime import datetime from dateutil.relativedelta import relativedelta from scipy.stats import zscore ############################################### # 1. Data Loading (Filtering Out Weekends) ############################################### 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. Removes weekend data. """ df = pd.read_excel(filepath, index_col=0) df.index = pd.to_datetime(df.index) df = df.sort_index() # Filter out weekends (Saturday=5, Sunday=6) df = df[df.index.dayofweek < 5] return df def load_macro_data(filepath): """ Load macro indicators from an Excel file. Loads VIX and VIX3M from 'Eq' sheet, LF98TRUU Index from 'FI' sheet, and other macro indicators from 'Macro' sheet. Removes weekend data. Also computes slopes for selected macro indicators. """ # VIX data vix_data = pd.read_excel(filepath, sheet_name='Eq', index_col=0, parse_dates=True, usecols=[0, 4, 5]) vix_data.columns = ['VIX', 'VIX3M'] vix_data = vix_data[vix_data.index.dayofweek < 5] # FI data (kept for completeness but not used in signals) cdx_data = pd.read_excel(filepath, sheet_name='FI', index_col=0, parse_dates=True, usecols=[0, 2], skiprows=1) cdx_data.columns = ['LF98TRUU'] cdx_data = cdx_data[cdx_data.index.dayofweek < 5] # Macro data (assumed to include columns "CESIUSD Index", "INJCJC Index", ".HG/GC G Index", "Consumer Confidence") macro_data = pd.read_excel(filepath, sheet_name='Macro', index_col=0, parse_dates=True, usecols=range(8), skiprows=1) macro_data = macro_data[macro_data.index.dayofweek < 5] # Compute slopes for selected macro indicators. macro_data["Surprise Index Slope"] = macro_data["CESIUSD Index"].diff() macro_data["Jobless Claims Slope"] = macro_data["INJCJC Index"].diff() macro_data["Copper Gold Slope"] = macro_data['.HG/GC G Index'].diff() # Assume "Consumer Confidence" column already exists. combined_data = pd.concat([vix_data, cdx_data, macro_data], axis=1) combined_data = combined_data.fillna(method='ffill').fillna(method='bfill') combined_data = combined_data.sort_index() # ensure sorted index return combined_data ############################################### # 2. Helper: Observation Dates (Monthly) ############################################### def get_observation_dates(prices, start_date, end_date, rebalance_period): dates = [] current_date = start_date while current_date < end_date: candidate_date = (current_date + relativedelta(months=rebalance_period)).replace(day=1) while candidate_date not in prices.index: candidate_date += pd.Timedelta(days=1) if candidate_date.month != (current_date + relativedelta(months=rebalance_period)).month: candidate_date = None break if candidate_date is None or candidate_date > end_date: break dates.append(candidate_date) current_date = candidate_date return dates ############################################### # 3. Portfolio Initialization ############################################### def initialize_portfolio(prices, date, tickers, initial_aum): portfolio = {} mask = prices.index < date prev_date = prices.index[mask][-1] allocation = initial_aum / len(tickers) for ticker in tickers: price = prices.loc[prev_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'): prices = prices.sort_index() mask = prices.index < current_date prev_date = prices.index[mask][-1] lookback_date = prev_date - relativedelta(months=lookback_period) current_price = prices[ticker].asof(prev_date) lookback_price = prices[ticker].asof(lookback_date) if pd.isna(current_price) or pd.isna(lookback_price): raise ValueError(f"Missing price data for {ticker} on {prev_date} or {lookback_date}.") if metric_type == 'simple': metric = (current_price / lookback_price) - 1 elif metric_type == 'sma': window = prices[ticker].loc[lookback_date:current_date] if window.empty: raise ValueError(f"No price data for {ticker} between {lookback_date} and {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): 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): value = 0 for ticker, quantity in portfolio.items(): price = prices.loc[current_date, ticker] value += quantity * price return value ############################################### # 7. Rebalance the Momentum Portfolio ############################################### def rebalance_portfolio(portfolio, prices, current_date, tickers, sorted_tickers, internal_rebalance_ratios, rebalance_ratio): mask = prices.index < current_date prev_date = prices.index[mask][-1] prev_prices = prices.loc[prev_date] curr_prices = prices.loc[current_date] portfolio_value = sum(portfolio[ticker] * prev_prices[ticker] for ticker in tickers) rebalance_amount = portfolio_value * rebalance_ratio target_trades = {ticker: rebalance_amount * internal_rebalance_ratios[i] for i, ticker in enumerate(sorted_tickers)} total_sold = 0 actual_trades = {} for ticker, target_trade in target_trades.items(): if target_trade < 0: available_notional = portfolio[ticker] * curr_prices[ticker] sell_target = abs(target_trade) actual_sell = min(available_notional, sell_target) actual_trades[ticker] = -actual_sell total_sold += actual_sell else: actual_trades[ticker] = 0 total_buy_target = sum(t for t in target_trades.values() if t > 0) if total_buy_target > 0: for ticker, target_trade in target_trades.items(): if target_trade > 0: proportion = target_trade / total_buy_target buy_amount = total_sold * proportion actual_trades[ticker] = buy_amount new_portfolio = portfolio.copy() for ticker, trade_notional in actual_trades.items(): execution_price = curr_prices[ticker] qty_change = trade_notional / execution_price new_portfolio[ticker] += qty_change return new_portfolio, actual_trades, portfolio_value def adjust_overweight(portfolio, prices, current_date, sorted_tickers, threshold=0.70): mask = prices.index < current_date prev_date = prices.index[mask][-1] prev_prices = prices.loc[prev_date] curr_prices = prices.loc[current_date] portfolio_value = sum(portfolio[ticker] * prev_prices[ticker] for ticker in portfolio) weights = {ticker: (portfolio[ticker] * prev_prices[ticker]) / portfolio_value for ticker in portfolio} new_portfolio = portfolio.copy() for overweight in portfolio: if weights[overweight] > threshold: extra_weight = weights[overweight] - threshold extra_value = extra_weight * portfolio_value execution_price_over = curr_prices[overweight] qty_reduce = extra_value / execution_price_over new_portfolio[overweight] -= qty_reduce remaining_value = extra_value for candidate in sorted_tickers: if candidate == overweight: continue candidate_value = new_portfolio[candidate] * curr_prices[candidate] candidate_weight = candidate_value / portfolio_value if candidate_weight < threshold: capacity = (threshold - candidate_weight) * portfolio_value allocation = min(remaining_value, capacity) qty_add = allocation / curr_prices[candidate] new_portfolio[candidate] += qty_add remaining_value -= allocation if remaining_value <= 0: break return new_portfolio ############################################### # 8. VIX-based Allocation Function ############################################### def momentum_allocation(vix_9d, vix_mean, vix_std, max_alloc=1.0, min_alloc=0.6): if vix_9d < vix_mean + 0.5 * vix_std: return max_alloc elif (vix_9d >= vix_mean + 0.5 * vix_std) and (vix_9d < vix_mean + 1 * vix_std): return 0.8 else: return min_alloc ############################################### # 9. Modularized Risk Signal Function ############################################### def generate_risk_signals(current_date, macro_data, prices, portfolio, macro_max_alloc, macro_min_alloc): """ Generates risk signals based solely on VIX indicators and current portfolio weights. Returns: - regime: 'risk-on' or 'risk-off' - target_alloc: target allocation for the momentum portfolio - vix_signal: derived VIX signal ('risk-on', 'risk-off', or 'no-signal') - note: any message regarding forced regime changes - vix_params: dictionary of key VIX parameters for logging purposes """ # Use the most recent macro data available prior to the current date. available_macro_dates = macro_data.index[macro_data.index < current_date] ref_date = available_macro_dates[-1] if len(available_macro_dates) > 0 else current_date # Retrieve VIX-related metrics. vix_1m = macro_data['VIX'].asof(ref_date) vix_3m = macro_data['VIX3M'].asof(ref_date) vix_spread = macro_data['VIX_Spread'].asof(ref_date) vix_ema = macro_data['VIX_Spread_EMA'].asof(ref_date) vix_mean = macro_data['Mean'].asof(ref_date) vix_std = macro_data['Std'].asof(ref_date) vix_target_alloc = momentum_allocation(vix_ema, vix_mean, vix_std, max_alloc=macro_max_alloc, min_alloc=macro_min_alloc) # Determine VIX signal based on EMA relative to thresholds. vix_signal = 'risk-off' if vix_ema >= (vix_mean + 0.5 * vix_std) else 'risk-on' if vix_ema <= (vix_mean - 0.5 * vix_std) else 'no-signal' # Get previous trading day from prices. mask = prices.index < current_date prev_date = prices.index[mask][-1] mom_value = compute_portfolio_value(portfolio, prices, prev_date) spy_weight = (portfolio.get('SPY US Equity', 0) * prices.loc[prev_date, 'SPY US Equity']) / mom_value if mom_value > 0 else 0 hyg_weight = (portfolio.get('HYG US Equity', 0) * prices.loc[prev_date, 'HYG US Equity']) / mom_value if mom_value > 0 else 0 note = "" # Logic to force risk-on if SPY+HYG weights are too low. if vix_signal == 'risk-off': if (spy_weight + hyg_weight) < 0.40: regime = 'risk-on' target_alloc = 1.0 note = "Forced regime to risk-on & target alloc 100% due to SPY+HYG < 40%" else: regime = 'risk-off' target_alloc = vix_target_alloc else: regime = 'risk-on' target_alloc = 1.0 vix_params = { 'vix_1m': vix_1m, 'vix_3m': vix_3m, 'vix_spread': vix_spread, 'vix_ema': vix_ema, 'vix_mean': vix_mean, 'vix_std': vix_std, 'vix_target_alloc': vix_target_alloc, 'spy_weight': spy_weight, 'hyg_weight': hyg_weight } return regime, target_alloc, vix_signal, note, vix_params ############################################### # 10. Helper Functions for Cash (using a cash ticker) ############################################### def invest_cash_into_portfolio(portfolio, prices, current_date, cash_qty, cash_ticker): """ When switching from risk-off to risk-on, sell the cash instrument (e.g. SHV) and reinvest its proceeds into the portfolio using previous-day weights. """ cash_price = prices.loc[current_date, cash_ticker] cash_available = cash_qty * cash_price if cash_available <= 0: return portfolio, cash_qty, f"No {cash_ticker} to reinvest." mask = prices.index < current_date prev_date = prices.index[mask][-1] mom_value = compute_portfolio_value(portfolio, prices, current_date) new_portfolio = portfolio.copy() for ticker in portfolio: prev_price = prices.loc[prev_date, ticker] total_qty = (portfolio[ticker] * (mom_value + cash_available)) / mom_value if mom_value > 0 else 1/len(portfolio) new_portfolio[ticker] = total_qty return new_portfolio, 0.0, f"Reinvested {cash_available:,.2f} from {cash_ticker}" def allocate_cash_from_portfolio(portfolio, prices, current_date, target_alloc, cash_qty, cash_ticker): """ Adjusts portfolio positions based on target allocation for cash. When deploying cash, scales up existing positions proportionally to reach target allocation. """ # Get previous date and calculate total portfolio value mask = prices.index < current_date prev_date = prices.index[mask][-1] curr_value = compute_portfolio_value(portfolio, prices, prev_date) # Calculate cash position value cash_price = prices.loc[prev_date, cash_ticker] cash_equiv = cash_qty * cash_price total_aum = curr_value + cash_equiv # Determine desired cash position desired_cash = (1 - target_alloc) * total_aum cash_price = prices.loc[current_date, cash_ticker] cash_equiv = cash_qty * cash_price current_cash = cash_equiv new_portfolio = portfolio.copy() new_cash_qty = cash_qty note = "" # If we need more cash, sell proportionally from other positions if desired_cash > current_cash: cash_to_raise = desired_cash - current_cash curr_value = compute_portfolio_value(portfolio, prices, current_date) for ticker in portfolio: price = prices.loc[current_date, ticker] ticker_value = portfolio[ticker] * price sell_ratio = (cash_to_raise / curr_value) qty_to_sell = portfolio[ticker] * sell_ratio new_portfolio[ticker] -= qty_to_sell new_cash_qty += cash_to_raise / cash_price note = f"Raised {cash_to_raise:,.2f} into {cash_ticker}" # If we have excess cash, scale up positions to reach target allocation elif desired_cash < current_cash: # Calculate current portfolio value at today's prices current_portfolio_value = sum( portfolio[ticker] * prices.loc[current_date, ticker] for ticker in portfolio ) # Calculate desired total risk allocation desired_risk_allocation = total_aum * target_alloc # Calculate scaling factor to reach target allocation scaling_factor = desired_risk_allocation / current_portfolio_value # Scale up all positions proportionally for ticker in portfolio: new_portfolio[ticker] = portfolio[ticker] * scaling_factor # Adjust cash position excess_cash = current_cash - desired_cash new_cash_qty -= excess_cash / cash_price note = f"Deployed {excess_cash:,.2f} from {cash_ticker} into portfolio" return new_portfolio, new_cash_qty, note ############################################### # 11. Simulation: Strategy ############################################### def simulate_strategy(prices, macro_data, eq_tickers, fi_tickers, alts_tickers, initial_aum, start_date, end_date, rebalance_period, rebalance_ratio, lookback_period, metric_type, internal_rebalance_ratios, cash_ticker='SHV US Equity', macro_max_alloc=1.0, macro_min_alloc=0.6): """ This simulation: - Uses a designated cash_ticker (e.g. "SHV US Equity") for the cash component. - Excludes the cash_ticker from momentum ranking/trading. - When target allocation is below 100% (risk-off), excess portfolio value is sold into the cash_ticker. - When switching back to risk-on, the cash position is reinvested. - The cash position is left untouched unless the allocation % changes. - The regime and allocation signals are now based solely on VIX. """ # Define tickers for momentum (exclude cash_ticker) all_tickers = eq_tickers + fi_tickers + alts_tickers momentum_tickers = [t for t in all_tickers if t != cash_ticker] monthly_dates = get_observation_dates(prices, start_date, end_date, rebalance_period) daily_dates = prices.index.sort_values() daily_dates = daily_dates[(daily_dates >= start_date) & (daily_dates <= end_date)] # Prepare macro data (remove FI_EMA calculations) macro_data = macro_data.copy() macro_data['VIX_Spread'] = macro_data['VIX'] - macro_data['VIX3M'] macro_data['VIX_Spread_EMA'] = macro_data["VIX_Spread"].ewm(span=12, adjust=False).mean() macro_data["Mean"] = macro_data['VIX_Spread_EMA'].rolling(window=504).mean() macro_data["Std"] = macro_data['VIX_Spread_EMA'].rolling(window=504).std() # Initialize portfolio (momentum securities only) and cash (in cash_ticker) portfolio = initialize_portfolio(prices, start_date, momentum_tickers, initial_aum) cash_qty = 0.0 # cash position is held as cash_ticker units current_regime, target_alloc = 'risk-on', 1.0 previous_regime, previous_target_alloc = current_regime, target_alloc prev_total_aum = initial_aum results = [] for current_date in daily_dates: daily_note = "No adjustment" cash_adjustment = 0.0 # --- Generate risk signals using the modularized function --- regime, target_alloc, vix_signal, signal_note, vix_params = generate_risk_signals( current_date, macro_data, prices, portfolio, macro_max_alloc, macro_min_alloc) current_regime = regime daily_note = signal_note # include any note from the risk signal generation # --- Cash rebalancing logic: Adjust cash position only when regime or target allocation changes --- if (previous_regime != current_regime) or (current_regime == 'risk-off' and target_alloc != previous_target_alloc): # Transition: risk-off -> risk-on: reinvest cash from cash_ticker into portfolio. if previous_regime == 'risk-off' and current_regime == 'risk-on' and cash_qty > 0: portfolio, cash_qty, note_update = invest_cash_into_portfolio(portfolio, prices, current_date, cash_qty, cash_ticker) daily_note += " | " + note_update # Transition: risk-on -> risk-off or target alloc changes while in risk-off: sell portfolio into cash_ticker. elif (previous_regime == 'risk-on' and current_regime == 'risk-off') or (current_regime == 'risk-off' and target_alloc != previous_target_alloc): portfolio, cash_qty, note_update = allocate_cash_from_portfolio(portfolio, prices, current_date, target_alloc, cash_qty, cash_ticker) daily_note += " | " + note_update previous_regime = current_regime previous_target_alloc = target_alloc # --- Monthly Rebalancing --- if current_date in monthly_dates: sorted_tickers, ranks, metrics = rank_assets(prices, current_date, momentum_tickers, lookback_period, metric_type) # Get initial rebalanced portfolio temp_portfolio, trades, pre_rebalance_value = rebalance_portfolio( portfolio, prices, current_date, momentum_tickers, sorted_tickers, internal_rebalance_ratios, rebalance_ratio) # Apply overweight adjustment to temp_portfolio temp_portfolio = adjust_overweight(temp_portfolio, prices, current_date, sorted_tickers, threshold=0.70) # Now calculate values using the properly adjusted temp_portfolio temp_value = compute_portfolio_value(temp_portfolio, prices, current_date) spy_temp = temp_portfolio.get('SPY US Equity', 0) * prices.loc[current_date, 'SPY US Equity'] hyg_temp = temp_portfolio.get('HYG US Equity', 0) * prices.loc[current_date, 'HYG US Equity'] combined_weight = (spy_temp + hyg_temp) / temp_value if temp_value > 0 else 0 if (current_regime == 'risk-off') and (combined_weight < 0.40): current_regime = 'risk-on' target_alloc = 1.0 daily_note += " | Monthly: Forced risk-on due to SPY+HYG weight < 40% after simulation." total_aum = compute_portfolio_value(portfolio, prices, current_date) + cash_qty * prices.loc[current_date, cash_ticker] simulated_value = temp_value new_portfolio = {} for ticker in temp_portfolio: price = prices.loc[current_date, ticker] simulated_weight = (temp_portfolio[ticker] * price) / simulated_value if simulated_value > 0 else 1/len(temp_portfolio) new_qty = (total_aum * simulated_weight) / price new_portfolio[ticker] = new_qty portfolio = new_portfolio cash_qty = 0 else: portfolio = temp_portfolio # Now using the already-adjusted temp_portfolio curr_value = compute_portfolio_value(portfolio, prices, current_date) total_aum = curr_value + cash_qty * prices.loc[current_date, cash_ticker] desired_value = target_alloc * total_aum if curr_value > desired_value: portfolio, cash_qty, note_update = allocate_cash_from_portfolio(portfolio, prices, current_date, target_alloc, cash_qty, cash_ticker) daily_note += " | Monthly: " + note_update elif curr_value < desired_value and cash_qty > 0: portfolio, cash_qty, note_update = allocate_cash_from_portfolio(portfolio, prices, current_date, target_alloc, cash_qty, cash_ticker) daily_note += " | Monthly: " + note_update else: # For days without monthly rebalancing, initialize these dictionaries as empty. ranks, metrics, trades = {}, {}, {} # --- Update daily AUM calculation --- current_mom_value = compute_portfolio_value(portfolio, prices, current_date) cash_price = prices.loc[current_date, cash_ticker] cash_value = cash_qty * cash_price total_aum = current_mom_value + cash_value ret = (total_aum - prev_total_aum) / prev_total_aum if prev_total_aum > 0 else 0 prev_total_aum = total_aum # --- Log daily results --- row = { 'Total AUM': total_aum, 'Momentum AUM': current_mom_value, 'Cash Qty': cash_qty, 'Cash Price': cash_price, 'Cash Value': cash_value, 'Current Regime': current_regime, 'Target Alloc': target_alloc, 'VIX Target': vix_params['vix_target_alloc'], 'VIX Signal': vix_signal, 'Adjustment Note': daily_note, 'Cash Adjustment': cash_adjustment, 'Return': ret, 'Event': 'Monthly Rebalance' if current_date in monthly_dates else 'Daily Check', 'VIX_1M': vix_params['vix_1m'], 'VIX_3M': vix_params['vix_3m'], 'VIX_Spread': vix_params['vix_spread'], "VIX_Spread_EMA": vix_params['vix_ema'], "VIX_Mean": vix_params['vix_mean'], "VIX_std": vix_params['vix_std'], 'Date': current_date } # Group 1: Quantities for each ticker for ticker in momentum_tickers: qty = portfolio[ticker] row[f'qty_{ticker}'] = qty # Group 2: Prices for each ticker for ticker in momentum_tickers: price = prices.loc[current_date, ticker] row[f'price_{ticker}'] = price # Group 3: Notionals for each ticker for ticker in momentum_tickers: price = prices.loc[current_date, ticker] qty = portfolio[ticker] notional = qty * price row[f'notional_{ticker}'] = notional # Group 4: Weights for each ticker for ticker in momentum_tickers: price = prices.loc[current_date, ticker] qty = portfolio[ticker] notional = qty * price weight = (notional / current_mom_value) if current_mom_value > 0 else np.nan row[f'weight_{ticker}'] = weight # Group 5: Ranks for each ticker (if available on monthly rebalancing days) for ticker in momentum_tickers: row[f'rank_{ticker}'] = ranks.get(ticker, np.nan) if current_date in monthly_dates else np.nan # Group 6: Metrics for each ticker (if available on monthly rebalancing days) for ticker in momentum_tickers: row[f'metric_{ticker}'] = metrics.get(ticker, np.nan) if current_date in monthly_dates else np.nan # Group 7: Trades for each ticker (if available on monthly rebalancing days) for ticker in momentum_tickers: row[f'trade_{ticker}'] = trades.get(ticker, 0) if current_date in monthly_dates else 0 results.append(row) result_df = pd.DataFrame(results) result_df.set_index('Date', inplace=True) return result_df ############################################### # 12. Main – Example Usage ############################################### if __name__ == '__main__': # Define asset tickers. eq_tickers = ['SPY US Equity'] fi_tickers = ['TLT US Equity', 'HYG US Equity'] # FI tickers are still included if desired alts_tickers = ['GLD US Equity', 'IGSB US Equity'] initial_aum = 100e6 start_date = pd.to_datetime('2008-01-01') end_date = pd.to_datetime('2025-02-01') rebalance_period = 1 rebalance_ratio = 0.2 lookback_period = 6 metric_type = 'simple' internal_rebalance_ratios = [0.8, 0.2, 0, -0.2, -0.8] # File paths (adjust these to your environment). price_filepath = r"\\asiapac.nom\data\MUM\IWM\India_IWM_IPAS\Reet\Momentum Strategy\Codes\Historic Prices.xlsx" macro_filepath = r"\\asiapac.nom\data\MUM\IWM\India_IWM_IPAS\Reet\Momentum Strategy\Momentum Strategy Overlay Data.xlsx" prices = load_price_data(price_filepath) macro_data = load_macro_data(macro_filepath) # Run simulation. result_df = simulate_strategy(prices, macro_data, eq_tickers, fi_tickers, alts_tickers, initial_aum, start_date, end_date, rebalance_period, rebalance_ratio, lookback_period, metric_type, internal_rebalance_ratios, cash_ticker='SHV US Equity', macro_max_alloc=1.0, macro_min_alloc=0.6) pd.set_option('display.float_format', lambda x: f'{x:,.2f}') # For example, print a summary of the simulation. print(result_df[['Total AUM', 'Momentum AUM', 'Cash Price']].tail())
Editor is loading...
Leave a Comment