A Novel Way to Control Risks

Kelly Betting is best know for optimizing returns. But it can also be used to control risks.

The general formula for Kelly betting is edge/odds.

I used (Probability of beating SPY)/Variance for 10 ETFs (Nine SPDRS and TLT). with a sliding 60 trading day window, rebalancing monthly.

It did reduce risk (especially drawdown in 2008) with little loss in returns. Some could consider using some leverage to get closer to "Optimal Kelly."

The results:

The code for those interested. You may have to use" !pip install yfinance" in a cell but it should run after doing this. You can change the parameters and the ETFs after that if you wish:


import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf
from tqdm import tqdm

class MultiStockBPW:
    def __init__(self, stock_tickers: list, benchmark: str = "SPY", n_bootstrap: int = 1000, 
                 window_size: int = 60, rebalance_freq: str = 'M'):
        self.stock_tickers = stock_tickers
        self.benchmark = benchmark
        self.n_bootstrap = n_bootstrap
        self.window_size = window_size
        self.rebalance_freq = rebalance_freq
        
    def prepare_data(self, start_date: str, end_date: str) -> tuple:
        """Download and prepare return data for all stocks and benchmark"""
        print("Downloading data...")
        
        # Download benchmark data first
        bench_data = yf.download(self.benchmark, start=start_date, end=end_date, progress=False)
        if bench_data.empty:
            raise ValueError(f"No data available for benchmark {self.benchmark}")
            
        # Initialize returns DataFrame with benchmark
        all_returns = pd.DataFrame(index=bench_data.index)
        all_returns[self.benchmark] = bench_data['Adj Close'].pct_change() * 100
        
        # Track which stocks have valid data
        valid_stocks = []
        
        # Download stock data
        for ticker in tqdm(self.stock_tickers, desc="Downloading stocks"):
            try:
                stock_data = yf.download(ticker, start=start_date, end=end_date, progress=False)
                if not stock_data.empty:
                    returns = stock_data['Adj Close'].pct_change() * 100
                    # Check if we have enough data
                    if len(returns) >= len(bench_data.index) * 0.5:  # At least 50% of benchmark data
                        all_returns[ticker] = returns
                        valid_stocks.append(ticker)
                    else:
                        print(f"Insufficient data for {ticker}, excluding from analysis")
            except Exception as e:
                print(f"Error downloading {ticker}: {e}")
        
        if not valid_stocks:
            raise ValueError("No valid stocks found")
            
        print(f"\nUsing {len(valid_stocks)} stocks: {', '.join(valid_stocks)}")
        
        # Drop any remaining NaN values
        all_returns = all_returns.dropna()
        
        return all_returns, valid_stocks
        
    def bootstrap_excess_returns(self, historical_excess: pd.Series) -> float:
        values = historical_excess.values.flatten()
        bootstrap_samples = np.random.choice(
            values,
            size=(self.n_bootstrap, len(values)),
            replace=True
        )
        bootstrap_means = np.mean(bootstrap_samples, axis=1)
        return float(np.mean(bootstrap_means > 0))
    
    def calculate_weights(self, data: pd.DataFrame, valid_stocks: list, window_start: int, t: int) -> pd.Series:
        """Calculate weights based on probability/variance ratio"""
        stock_metrics = []
        
        for stock in valid_stocks:
            # Get historical data for this window
            stock_returns = data[stock].iloc[window_start:t]
            historical_excess = stock_returns - data[self.benchmark].iloc[window_start:t]
            
            # Calculate probability of beating benchmark
            prob = self.bootstrap_excess_returns(historical_excess)
            
            # Calculate variance
            variance = stock_returns.var()
            
            # Handle zero variance case
            if variance == 0:
                ratio = 0
            else:
                ratio = prob / variance
                
            stock_metrics.append({
                'stock': stock,
                'prob': prob,
                'variance': variance,
                'ratio': ratio
            })
        
        # Create DataFrame for better visibility of the metrics
        metrics_df = pd.DataFrame(stock_metrics)
        
        # Calculate normalized weights
        total_ratio = metrics_df['ratio'].sum()
        
        if total_ratio > 0:
            weights = pd.Series(
                [metric['ratio'] / total_ratio for metric in stock_metrics],
                index=valid_stocks
            )
        else:
            # If all ratios are zero, use equal weights
            weights = pd.Series(1.0 / len(valid_stocks), index=valid_stocks)
            
        return weights
    
    def run_strategy(self, data: pd.DataFrame, valid_stocks: list) -> pd.DataFrame:
        """Run the modified multi-stock BPW strategy with periodic rebalancing"""
        print("Running strategy...")
        n_stocks = len(valid_stocks)
        
        # Initialize weight matrices
        stock_weights = pd.DataFrame(0.0, index=data.index, columns=valid_stocks)
        portfolio_returns = pd.Series(0.0, index=data.index)
        
        # Get rebalancing dates
        rebalance_dates = pd.date_range(
            start=data.index[0], 
            end=data.index[-1], 
            freq=self.rebalance_freq
        )
        # Only keep dates that exist in our data
        rebalance_dates = rebalance_dates[rebalance_dates.isin(data.index)]
        
        # Use equal weights until we have enough data for the first window
        initial_periods = max(self.window_size, 4)
        initial_weight = 1.0 / n_stocks
        first_rebalance_idx = data.index.get_loc(rebalance_dates[0])
        stock_weights.iloc[:first_rebalance_idx] = initial_weight
        
        # Calculate initial portfolio returns
        for t in range(first_rebalance_idx):
            portfolio_returns.iloc[t] = (stock_weights.iloc[t] * data[valid_stocks].iloc[t]).sum()
        
        # Main strategy loop - only compute new weights on rebalancing dates
        current_weights = pd.Series(initial_weight, index=valid_stocks)
        
        # Track metrics for analysis
        metrics_history = []
        
        for t in tqdm(range(first_rebalance_idx, len(data)), desc="Computing strategy"):
            current_date = data.index[t]
            
            # Update weights if this is a rebalancing date and we have enough history
            if current_date in rebalance_dates and t >= initial_periods:
                window_start = max(0, t - self.window_size)
                current_weights = self.calculate_weights(data, valid_stocks, window_start, t)
                
                # Store metrics for this rebalancing period
                metrics = {
                    'date': current_date,
                    'weights': current_weights.to_dict()
                }
                metrics_history.append(metrics)
            
            # Set weights for this period
            stock_weights.iloc[t] = current_weights
            
            # Calculate portfolio return using current weights
            portfolio_returns.iloc[t] = (current_weights * data[valid_stocks].iloc[t]).sum()
        
        results = pd.DataFrame({
            'returns': portfolio_returns,
            **stock_weights
        })
        
        # Add metrics history to results
        results.attrs['metrics_history'] = metrics_history
        
        return results
    
    def analyze_portfolio(self, start_date: str, end_date: str):
        """Run complete portfolio analysis"""
        try:
            # Prepare data
            data, valid_stocks = self.prepare_data(start_date, end_date)
            
            # Run strategy
            results = self.run_strategy(data, valid_stocks)
            
            # Calculate number of rebalances
            rebalance_dates = pd.date_range(
                start=data.index[0], 
                end=data.index[-1], 
                freq=self.rebalance_freq
            )
            n_rebalances = len(rebalance_dates[rebalance_dates.isin(data.index)])
            
            # Calculate cumulative returns for drawdown analysis
            cum_strategy = (1 + results['returns']/100).cumprod()
            cum_benchmark = (1 + data[self.benchmark]/100).cumprod()
            
            # Calculate drawdowns
            rolling_max_strategy = cum_strategy.expanding().max()
            rolling_max_benchmark = cum_benchmark.expanding().max()
            drawdown_strategy = (cum_strategy - rolling_max_strategy) / rolling_max_strategy * 100
            drawdown_benchmark = (cum_benchmark - rolling_max_benchmark) / rolling_max_benchmark * 100
            
            # Calculate performance metrics
            strategy_returns = results['returns']
            benchmark_returns = data[self.benchmark]
            
            # Annualized returns (using 252 trading days)
            strategy_ann_return = (1 + strategy_returns.mean()/100)**252 - 1
            benchmark_ann_return = (1 + benchmark_returns.mean()/100)**252 - 1
            
            # Annualized volatility
            strategy_ann_vol = (strategy_returns/100).std() * np.sqrt(252)
            benchmark_ann_vol = (benchmark_returns/100).std() * np.sqrt(252)
            
            # Sharpe ratios (assuming 0% risk-free rate for simplicity)
            strategy_sharpe = strategy_ann_return / strategy_ann_vol
            benchmark_sharpe = benchmark_ann_return / benchmark_ann_vol
            
            # Print performance summary
            print("\n=== Strategy Settings ===")
            print(f"Window Size: {self.window_size} trading days")
            print(f"Rebalancing Frequency: {self.rebalance_freq}")
            print(f"Total Rebalances: {n_rebalances}")
            print("Weighting Method: Probability/Variance ratio")
            
            print("\n=== Portfolio Strategy Performance ===")
            print(f"Annual Return: {strategy_ann_return*100:.2f}%")
            print(f"Annual Volatility: {strategy_ann_vol*100:.2f}%")
            print(f"Sharpe Ratio: {strategy_sharpe:.2f}")
            print(f"Max Drawdown: {drawdown_strategy.min():.2f}%")
            
            print(f"\n=== {self.benchmark} Performance ===")
            print(f"Annual Return: {benchmark_ann_return*100:.2f}%")
            print(f"Annual Volatility: {benchmark_ann_vol*100:.2f}%")
            print(f"Sharpe Ratio: {benchmark_sharpe:.2f}")
            print(f"Max Drawdown: {drawdown_benchmark.min():.2f}%")
            
            print("\nFinal Portfolio Weights:")
            for stock in valid_stocks:
                print(f"{stock}: {results[stock].iloc[-1]:.2%}")
            
            # Plot results
            fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(15, 18))
            fig.suptitle(f'Multi-Stock BPW Strategy (Prob/Var)\n(Window: {self.window_size} days, Rebalance: {self.rebalance_freq})')
            
            # Plot cumulative returns
            cum_strategy_plot = (cum_strategy - 1) * 100
            cum_benchmark_plot = (cum_benchmark - 1) * 100
            ax1.plot(data.index, cum_strategy_plot, label='Strategy', linewidth=2)
            ax1.plot(data.index, cum_benchmark_plot, label=self.benchmark, linestyle=':', linewidth=2)
            ax1.set_title('Cumulative Returns')
            ax1.grid(True)
            ax1.legend()
            
            # Plot drawdowns
            ax2.plot(data.index, drawdown_strategy, label='Strategy', linewidth=2)
            ax2.plot(data.index, drawdown_benchmark, label=self.benchmark, linestyle=':', linewidth=2)
            ax2.set_title('Drawdowns')
            ax2.grid(True)
            ax2.legend()
            
            # Plot weights
            for stock in valid_stocks:
                ax3.plot(data.index, results[stock], label=stock, alpha=0.7, linewidth=1)
            ax3.set_title('Portfolio Weights Evolution')
            ax3.grid(True)
            ax3.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
            
            plt.tight_layout()
            plt.show()
            
            return results
            
        except Exception as e:
            print(f"Error in portfolio analysis: {str(e)}")
            return None
        
                                # Run the analysis
if __name__ == "__main__":
    stocks = ["XLE", "XLU", "XLK", "XLB", "XLP", 
              "XLY", "XLI", "XLV", "XLF", "TLT"]
    
    portfolio = MultiStockBPW(stocks)
    results = portfolio.analyze_portfolio("2006-01-01", "2024-01-01")

3 Likes

Thanks for presenting interesting concept.

Indeed, it works in P123, even without assigning specific weights (I do not have access to dynamic weights feature).

This formula should do most of the job:

@prob_outperf_spy: ((LoopSum(`FHist("Rel%Chg(1,GetSeries(`SPY`))", CTR) > 0`,60)/ 60) / TRSD1YD)

You can double check if the formula is correct.

I created a public screen that selects etfs every 4 weeks which has zscore(@prob_outperf_spy) > 0. I added also GLD to the universe. Feel free to modify this Screen or create simulation with dynamic weights.

4 Likes

Small mistake in the formula... it uses 60 weeks window, so you may want to change it to 13 weeks to get ~3 months:

@prob_outperf_spy: ((LoopSum(`FHist("Rel%Chg(1,GetSeries(`SPY`))", CTR) > 0`,13)/ 13) / TRSD1YD)

Interestingly, the performance is even better with 13 weeks rather than 60 weeks.

2 Likes

Thank you Piotr!!!. Nice screen and great idea to check different window sizes. I went ahead and did a grid-search for the optimal sliding window size for my Python program. As you know there will be differences between the Python program and the screen. That is to be expected. And again, nice screen and posts!!!!!

Screenshot 2024-11-04 at 10.55.54 AM

I tested some logical extensions of this. If all ETFs have a negative prob, just hold SPY. If SPY and all ETFs have a negative prob, hold cash. Also a threshold for prob where it doesn't hold the stock if <50%. This just makes it more consistent with other "Tactical Asset Allocation" strategies that tend to have a Risk-Off (cash or bonds) and Risk-On (equity) mode along with concentration in the top ranking ETFs.

However it doesn't appear worth the added complication

=== Portfolio Strategy Performance ===
Annual Return: 11.59%
Annual Volatility: 14.95%
Sharpe Ratio: 0.78
Max Drawdown: -31.50%

def calculate_weights(self, data: pd.DataFrame, valid_stocks: list, window_start: int, t: int, prob_threshold: float = 0.5) -> pd.Series:
    """Calculate weights based on probability/variance ratio with fallback to SPY or cash."""
    stock_metrics = []
    all_probs = []  # Track probabilities for all stocks

    for stock in valid_stocks:
        # Get historical data for this window
        stock_returns = data[stock].iloc[window_start:t]
        historical_excess = stock_returns - data[self.benchmark].iloc[window_start:t]
        
        # Calculate probability of beating benchmark
        prob = self.bootstrap_excess_returns(historical_excess)
        all_probs.append(prob)  # Store probabilities
        
        # Initialize metrics with default values
        variance = 0
        ratio = 0
        
        # If probability meets the threshold, calculate variance and ratio
        if prob >= prob_threshold:
            variance = stock_returns.var()
            ratio = prob / variance if variance > 0 else 0
            
        # Append metrics to list
        stock_metrics.append({
            'stock': stock,
            'prob': prob,
            'variance': variance,
            'ratio': ratio
        })
    
    # Calculate SPY's probability of positive returns
    spy_returns = data[self.benchmark].iloc[window_start:t]
    spy_prob = self.bootstrap_excess_returns(spy_returns)

    # Check conditions for moving to cash
    if all(p <= 0 for p in all_probs) and spy_prob <= 0:
        print(f"All probabilities <= 0 and SPY probability <= 0 at time {data.index[t]}. Moving to cash.")
        weights = pd.Series(0.0, index=valid_stocks)
        weights[self.benchmark] = 0.0  # Explicitly set SPY weight to 0
        return weights

    # Check conditions for switching to SPY
    if all(p <= 0 for p in all_probs):
        print(f"All probabilities <= 0 at time {data.index[t]}. Switching to benchmark.")
        weights = pd.Series(0.0, index=valid_stocks)  # Zero weight to all stocks
        weights[self.benchmark] = 1.0  # Allocate 100% to SPY
        return weights

    # Create DataFrame for better visibility of the metrics
    metrics_df = pd.DataFrame(stock_metrics)
    
    # Calculate normalized weights
    total_ratio = metrics_df['ratio'].sum()
    
    if total_ratio > 0:
        weights = pd.Series(
            [metric['ratio'] / total_ratio for metric in stock_metrics],
            index=valid_stocks
        )
    else:
        # If all ratios are zero, fallback to holding SPY
        weights = pd.Series(0.0, index=valid_stocks)
        weights[self.benchmark] = 1.0  # Allocate 100% to SPY
    
    return weights
1 Like

For those still interest in the Python version of this general idea, this code gives better returns but has problems with high turnover and getting overly concentrated at times with 60% of the portfolio being in one ETF at one point.

This is the same idea but uses Kelly Formula for continuous returns (Expected Return)/Variance. Expected return is determined using a linear regression model for this ETF string in the code: ["XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV", "XLF", "TLT", "GLD"].

I think this has relevance to this discussion also. Relevance in that linear regression may be a more nuanced method for using trending and mean reversion in a model than some other methods for both sectors and factors: I really wish factor momentum would work - help me show that it actually does

Code for replication or if someone wants to modify it in an attempt make something useful of it (e.g., limit the turnover or concentration):


import numpy as np
import pandas as pd
import yfinance as yf
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression
from tqdm import tqdm
import matplotlib.pyplot as plt

class LinearRegressionPortfolioStrategy:
    def __init__(self, stock_tickers: list, benchmark: str = "SPY", 
                 lookback_months: int = 24, rebalance_freq: str = 'M'):
        self.stock_tickers = stock_tickers
        self.benchmark = benchmark
        self.lookback_months = lookback_months
        self.rebalance_freq = rebalance_freq
        self.scaler = StandardScaler()
        
    def prepare_data(self, start_date: str, end_date: str) -> tuple:
        print("Downloading data...")
        
        start_date_with_buffer = pd.to_datetime(start_date) - pd.DateOffset(months=self.lookback_months)
        end_date = pd.to_datetime(end_date)
        
        bench_data = yf.download(self.benchmark, start=start_date_with_buffer, end=end_date, progress=False)
        if bench_data.empty:
            raise ValueError(f"No data available for benchmark {self.benchmark}")
        
        # Convert index to timezone-naive
        bench_data.index = bench_data.index.tz_localize(None)
        
        all_returns = pd.DataFrame(index=bench_data.index)
        all_returns[self.benchmark] = bench_data['Adj Close'].pct_change() * 100
        
        valid_stocks = []
        
        for ticker in tqdm(self.stock_tickers, desc="Downloading stocks"):
            try:
                stock_data = yf.download(ticker, start=start_date_with_buffer, end=end_date, progress=False)
                stock_data.index = stock_data.index.tz_localize(None)
                
                if not stock_data.empty:
                    returns = stock_data['Adj Close'].pct_change() * 100
                    if len(returns) >= len(bench_data.index) * 0.5:
                        all_returns[ticker] = returns
                        valid_stocks.append(ticker)
                    else:
                        print(f"Insufficient data for {ticker}, excluding from analysis")
            except Exception as e:
                print(f"Error downloading {ticker}: {e}")
                
        if not valid_stocks:
            raise ValueError("No valid stocks found")
            
        print(f"\nUsing {len(valid_stocks)} stocks: {', '.join(valid_stocks)}")
        return all_returns.dropna(), valid_stocks

    def predict_returns(self, data: pd.DataFrame, stock: str, current_idx: int) -> float:
        dates = data.index
        current_date = dates[current_idx]
        
        month_start = pd.Timestamp(current_date.year, current_date.month, 1)
        prev_month_end = month_start - pd.Timedelta(days=1)
        lookback_start = (prev_month_end - pd.DateOffset(months=self.lookback_months))
        
        mask = (dates > lookback_start) & (dates <= prev_month_end)
        monthly_data = data[mask][stock].resample('M').last()
        
        if len(monthly_data) < 2:
            return 0.0
        
        X = np.arange(len(monthly_data)).reshape(-1, 1)
        y = monthly_data.values
        
        model = LinearRegression()
        model.fit(X, y)
        
        next_X = np.array([[len(monthly_data)]])
        prediction = model.predict(next_X)[0]
        
        return prediction
    
    def calculate_weights(self, data: pd.DataFrame, valid_stocks: list, current_idx: int) -> pd.Series:
        stock_metrics = []
        
        dates = data.index
        current_date = dates[current_idx]
        month_start = pd.Timestamp(current_date.year, current_date.month, 1)
        prev_month_end = month_start - pd.Timedelta(days=1)
        lookback_start = prev_month_end - pd.DateOffset(months=self.lookback_months)
        
        for stock in valid_stocks:
            predicted_return = self.predict_returns(data, stock, current_idx)
            
            mask = (dates > lookback_start) & (dates <= prev_month_end)
            monthly_data = data[mask][stock].resample('M').last()
            variance = monthly_data.var()
            
            if variance > 0:
                ratio = predicted_return / variance
            else:
                ratio = 0
                
            stock_metrics.append({
                'stock': stock,
                'predicted_return': predicted_return,
                'variance': variance,
                'ratio': ratio
            })
        
        metrics_df = pd.DataFrame(stock_metrics)
        total_ratio = metrics_df['ratio'].sum()
        
        if total_ratio != 0:
            weights = pd.Series(
                [metric['ratio'] / total_ratio for metric in stock_metrics],
                index=valid_stocks
            )
            weights[weights < 0] = 0
            if weights.sum() > 0:
                weights = weights / weights.sum()
            else:
                weights = pd.Series(1.0 / len(valid_stocks), index=valid_stocks)
        else:
            weights = pd.Series(1.0 / len(valid_stocks), index=valid_stocks)
            
        return weights
    
    def run_strategy(self, data: pd.DataFrame, valid_stocks: list) -> pd.DataFrame:
        print("Running strategy...")
        
        stock_weights = pd.DataFrame(0.0, index=data.index, columns=valid_stocks)
        portfolio_returns = pd.Series(0.0, index=data.index)
        
        rebalance_dates = pd.date_range(
            start=data.index[0], 
            end=data.index[-1], 
            freq=self.rebalance_freq
        )
        
        rebalance_dates = rebalance_dates[rebalance_dates.isin(data.index)]
        
        initial_periods = self.lookback_months * 21
        initial_weight = 1.0 / len(valid_stocks)
        first_rebalance_idx = data.index.get_loc(rebalance_dates[0])
        stock_weights.iloc[:first_rebalance_idx] = initial_weight
        
        for t in range(first_rebalance_idx):
            portfolio_returns.iloc[t] = (stock_weights.iloc[t] * data[valid_stocks].iloc[t]).sum()
        
        current_weights = pd.Series(initial_weight, index=valid_stocks)
        
        for t in tqdm(range(first_rebalance_idx, len(data)), desc="Computing strategy"):
            current_date = data.index[t]
            
            if current_date in rebalance_dates and t >= initial_periods:
                current_weights = self.calculate_weights(data, valid_stocks, t)
            
            stock_weights.iloc[t] = current_weights
            portfolio_returns.iloc[t] = (current_weights * data[valid_stocks].iloc[t]).sum()
        
        return pd.DataFrame({
            'returns': portfolio_returns,
            **stock_weights
        })
    
    def analyze_portfolio(self, start_date: str, end_date: str):
        try:
            data, valid_stocks = self.prepare_data(start_date, end_date)
            
            # Trim the data to the requested period after using lookback
            start_date_dt = pd.to_datetime(start_date).tz_localize(None)
            data = data[data.index >= start_date_dt]
            
            results = self.run_strategy(data, valid_stocks)
            
            cum_strategy = (1 + results['returns']/100).cumprod()
            cum_benchmark = (1 + data[self.benchmark]/100).cumprod()
            
            rolling_max_strategy = cum_strategy.expanding().max()
            rolling_max_benchmark = cum_benchmark.expanding().max()
            drawdown_strategy = (cum_strategy - rolling_max_strategy) / rolling_max_strategy * 100
            drawdown_benchmark = (cum_benchmark - rolling_max_benchmark) / rolling_max_benchmark * 100
            
            strategy_returns = results['returns']
            benchmark_returns = data[self.benchmark]
            
            strategy_ann_return = (1 + strategy_returns.mean()/100)**252 - 1
            benchmark_ann_return = (1 + benchmark_returns.mean()/100)**252 - 1
            
            strategy_ann_vol = (strategy_returns/100).std() * np.sqrt(252)
            benchmark_ann_vol = (benchmark_returns/100).std() * np.sqrt(252)
            
            strategy_sharpe = strategy_ann_return / strategy_ann_vol
            benchmark_sharpe = benchmark_ann_return / benchmark_ann_vol

            # Calculate turnover
            weights_diff = results[valid_stocks].diff().abs()
            turnover = weights_diff.sum(axis=1)
            avg_monthly_turnover = turnover[turnover != 0].mean()
            annual_turnover = avg_monthly_turnover * 12

            # Calculate concentration (HHI)
            hhi = (results[valid_stocks] ** 2).sum(axis=1)
            avg_hhi = hhi.mean()
            max_hhi = hhi.max()
            min_hhi = 1/len(valid_stocks)
            
            print("\n=== Strategy Settings ===")
            print(f"Lookback Months: {self.lookback_months}")
            print(f"Rebalancing Frequency: {self.rebalance_freq}")
            
            print("\n=== Portfolio Strategy Performance ===")
            print(f"Annual Return: {strategy_ann_return*100:.2f}%")
            print(f"Annual Volatility: {strategy_ann_vol*100:.2f}%")
            print(f"Sharpe Ratio: {strategy_sharpe:.2f}")
            print(f"Max Drawdown: {drawdown_strategy.min():.2f}%")
            
            print(f"\n=== {self.benchmark} Performance ===")
            print(f"Annual Return: {benchmark_ann_return*100:.2f}%")
            print(f"Annual Volatility: {benchmark_ann_vol*100:.2f}%")
            print(f"Sharpe Ratio: {benchmark_sharpe:.2f}")
            print(f"Max Drawdown: {drawdown_benchmark.min():.2f}%")

            print("\n=== Portfolio Characteristics ===")
            print(f"Average Monthly Turnover: {avg_monthly_turnover*100:.2f}%")
            print(f"Annual Turnover: {annual_turnover*100:.2f}%")
            print(f"Average HHI: {avg_hhi:.3f} (Equal weight: {min_hhi:.3f})")
            print(f"Max HHI: {max_hhi:.3f}")
            
            fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(20, 15))
            fig.suptitle('Linear Regression Portfolio Strategy\n(Returns/Variance Optimization)')
            
            cum_strategy_plot = (cum_strategy - 1) * 100
            cum_benchmark_plot = (cum_benchmark - 1) * 100
            ax1.plot(data.index, cum_strategy_plot, label='Strategy', linewidth=2)
            ax1.plot(data.index, cum_benchmark_plot, label=self.benchmark, linestyle=':', linewidth=2)
            ax1.set_title('Cumulative Returns')
            ax1.grid(True)
            ax1.legend()
            
            ax2.plot(data.index, drawdown_strategy, label='Strategy', linewidth=2)
            ax2.plot(data.index, drawdown_benchmark, label=self.benchmark, linestyle=':', linewidth=2)
            ax2.set_title('Drawdowns')
            ax2.grid(True)
            ax2.legend()
            
            for stock in valid_stocks:
                ax3.plot(data.index, results[stock], label=stock, alpha=0.7, linewidth=1)
            ax3.set_title('Portfolio Weights Evolution')
            ax3.grid(True)
            ax3.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
            
            ax4.plot(data.index, turnover * 100, label='Monthly Turnover', color='blue', alpha=0.7)
            ax4_twin = ax4.twinx()
            ax4_twin.plot(data.index, hhi, label='HHI', color='red', alpha=0.7)
            ax4.set_title('Portfolio Turnover and Concentration')
            ax4.set_ylabel('Monthly Turnover (%)', color='blue')
            ax4_twin.set_ylabel('HHI', color='red')
            ax4.grid(True)
            
            lines1, labels1 = ax4.get_legend_handles_labels()
            lines2, labels2 = ax4_twin.get_legend_handles_labels()
            ax4_twin.legend(lines1 + lines2, labels1 + labels2, loc='upper right')
            
            plt.tight_layout()
            plt.show()
            
            return results
            
        except Exception as e:
            print(f"Error in portfolio analysis: {str(e)}")
            return None

if __name__ == "__main__":
    stocks = ["XLE", "XLU", "XLK", "XLB", "XLP", 
              "XLY", "XLI", "XLV", "XLF", "TLT", "GLD"]
    
    portfolio = LinearRegressionPortfolioStrategy(
        stocks,
        lookback_months=24,
        rebalance_freq='M'
    )
    results = portfolio.analyze_portfolio("2001-01-01", "2024-11-01")

1 Like

I see that this thread is filled with coders who appear to have a good understanding of Python :wink: I have been working for a while on something relatively simple: creating a system that allows for backtesting and viewing the latest signal from the Bold Asset Allocation, aggressive version. Bold Asset Allocation - Allocate Smartly

It should be fairly straightforward, yet I am unable to get the returns to display correctly at all. This issue persists even when I tried to incorporate the combination from Jrinne's latest script.

I don't understand why the returns are nowhere near the 11-12% since 2016. Is there anyone who can identify the error?

image

I see that attempts to resolve this have been made previously: Anyone know how to build these in P123 - #11 by Whycliffes

import backtrader as bt
import pandas as pd
import numpy as np
import yfinance as yf
from datetime import datetime, timedelta
import time
from collections import OrderedDict
import matplotlib.pyplot as plt

class KeyllyStrategy(bt.Strategy):
    params = dict(
        lookback_months=12,
        defensive_allocation=1/3,
        rebalance_threshold=0.01
    )

    def __init__(self):
        self.canary = ['SPY', 'EFA', 'EEM', 'AGG']
        self.offensive = ['QQQ']
        self.defensive = ['AGG', 'LQD', 'PDBC']
        self.safe = 'BIL'
        
        self.start_date = self.datas[0].datetime.date(0)
        self.end_date = self.datas[0].datetime.date(-1)
        
        self.monthly_returns = OrderedDict()
        current_date = pd.Timestamp(self.start_date)
        while current_date <= pd.Timestamp(self.end_date):
            self.monthly_returns[current_date.strftime('%Y-%m')] = (current_date.date(), None)
            current_date += pd.offsets.MonthEnd(1)
        
        self.annual_returns = {}
        self.monthly_closes = {asset: [] for asset in self.canary + self.offensive + self.defensive + [self.safe]}
        
        self.portfolio_start_value = self.broker.getvalue()
        self.month_start_value = self.portfolio_start_value
        self.year_start_value = self.portfolio_start_value
        
        self.current_month = None
        self.current_year = None
        self.last_month = None
        
        self.current_weights = {d._name: 0.0 for d in self.datas}
        self.target_weights = {d._name: 0.0 for d in self.datas}
        self.mode = 'defensive'
        
        self.pending_orders = []
        self.initialization_complete = False
        
        # Enhanced tracking
        self.portfolio_values = []
        self.positions_history = []
        self.daily_returns = []

    def notify_order(self, order):
        if order.status in [order.Completed]:
            self.pending_orders.remove(order)
            self.log(f"Order completed: {order.data._name}")

    def log(self, txt, dt=None):
        dt = dt or self.datas[0].datetime.date(0)
        print(f'{dt.isoformat()} - {txt}')

    def track_returns(self):
        if not self.initialization_complete:
            return
            
        current_date = self.datas[0].datetime.date(0)
        portfolio_value = sum(self.getposition(d).size * d.close[0] for d in self.datas)
        cash = self.broker.getcash()
        total_value = portfolio_value + cash
        
        self.portfolio_values.append((current_date, total_value))
        month_key = current_date.strftime('%Y-%m')
        
        if self.current_month is None:
            self.current_month = current_date.month
            self.current_year = current_date.year
            self.month_start_value = total_value
            self.year_start_value = total_value
            return
        
        if self.is_last_trading_day_of_month(current_date):
            monthly_return = ((total_value / self.month_start_value) - 1) * 100
            self.monthly_returns[month_key] = (current_date, monthly_return)
            self.month_start_value = total_value
            
            if current_date.year != self.current_year:
                annual_return = ((total_value / self.year_start_value) - 1) * 100
                self.annual_returns[self.current_year] = annual_return
                self.year_start_value = total_value
                self.current_year = current_date.year
            
            self.current_month = current_date.month
            self.log(f"Month End Value: {total_value:.2f}, Return: {monthly_return:.2f}%")
    def next(self):
        current_date = self.datas[0].datetime.date(0)
        
        if self.last_month != current_date.month:
            for asset in self.monthly_closes:
                data = self.get_data_by_name(asset)
                if data:
                    self.monthly_closes[asset].append(data.close[0])
            self.last_month = current_date.month

        if len(self.monthly_closes['SPY']) < 13:
            self.log("Building historical data...")
            return
            
        if not self.initialization_complete:
            self.initialization_complete = True
            self.month_start_value = self.broker.getvalue()
            
        self.track_returns()

        if not self.is_last_trading_day_of_month(current_date) or self.pending_orders:
            return

        canary_momentum = {asset: self.calculate_13612W_momentum(self.monthly_closes[asset])
                          for asset in self.canary}
        
        self.log(f"Canary momentum: {canary_momentum}")
        all_positive = all(mom > 0 for mom in canary_momentum.values())
        self.target_weights = {d._name: 0.0 for d in self.datas}
        
        portfolio_value = self.broker.getvalue()
        
        if all_positive:
            self.mode = 'offensive'
            self.target_weights['QQQ'] = 1.0
            self.log("Switching to offensive mode: 100% QQQ")
            
            for data in self.datas:
                if data._name == 'QQQ':
                    target_value = portfolio_value
                    target_size = target_value / data.close[0]
                    order = self.order_target_size(data, target_size)
                    self.pending_orders.append(order)
        else:
            self.mode = 'defensive'
            defensive_scores = {asset: self.calculate_relative_momentum(self.monthly_closes[asset])
                              for asset in self.defensive}
            
            sorted_assets = sorted(defensive_scores.items(), key=lambda x: x[1], reverse=True)
            best_defensive = sorted_assets[0][0]
            
            self.target_weights[best_defensive] = self.params.defensive_allocation
            self.target_weights[self.safe] = 1 - self.params.defensive_allocation
            
            self.log(f"Switching to defensive mode: {best_defensive}={self.params.defensive_allocation*100:.0f}%, {self.safe}={100-self.params.defensive_allocation*100:.0f}%")
            
            for data in self.datas:
                target_weight = self.target_weights[data._name]
                if target_weight > 0:
                    target_value = portfolio_value * target_weight
                    target_size = target_value / data.close[0]
                    order = self.order_target_size(data, target_size)
                    self.pending_orders.append(order)
        
        self.positions_history.append({
            'date': current_date,
            'mode': self.mode,
            'weights': self.target_weights.copy()
        })

    def calculate_13612W_momentum(self, prices):
        if len(prices) < 13:
            return -np.inf
        
        p0 = prices[-1]
        p1 = prices[-2]
        p3 = prices[-4]
        p6 = prices[-7]
        p12 = prices[-13]
        
        return (12 * (p0/p1 - 1)) + (4 * (p0/p3 - 1)) + (2 * (p0/p6 - 1)) + (p0/p12 - 1)

    def calculate_relative_momentum(self, prices):
        if len(prices) < 13:
            return -np.inf
        return prices[-1] / np.mean(prices[-13:]) - 1

    def get_data_by_name(self, name):
        return next((d for d in self.datas if d._name == name), None)

    def is_last_trading_day_of_month(self, dt):
        current_date = pd.Timestamp(dt)
        next_day = current_date + pd.Timedelta(days=1)
        return current_date.month != next_day.month

    def calculate_performance_metrics(self):
        portfolio_df = pd.DataFrame(self.portfolio_values, columns=['Date', 'Value'])
        portfolio_df.set_index('Date', inplace=True)
        returns = portfolio_df['Value'].pct_change()
        
        ann_return = (1 + returns).prod() ** (252/len(returns)) - 1
        ann_vol = returns.std() * np.sqrt(252)
        sharpe = ann_return / ann_vol if ann_vol != 0 else 0
        
        cum_returns = (1 + returns).cumprod()
        rolling_max = cum_returns.expanding().max()
        drawdowns = (cum_returns - rolling_max) / rolling_max
        
        return {
            'Annual Return': ann_return * 100,
            'Annual Volatility': ann_vol * 100,
            'Sharpe Ratio': sharpe,
            'Max Drawdown': drawdowns.min() * 100,
            'Cumulative Return': (cum_returns.iloc[-1] - 1) * 100
        }

    def plot_results(self):
        portfolio_df = pd.DataFrame(self.portfolio_values, columns=['Date', 'Value'])
        portfolio_df.set_index('Date', inplace=True)
        
        fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(20, 15))
        
        # Plot 1: Cumulative Returns
        returns = portfolio_df['Value'].pct_change()
        cum_returns = (1 + returns).cumprod()
        ax1.plot(cum_returns.index, (cum_returns - 1) * 100)
        ax1.set_title('Cumulative Returns (%)')
        ax1.grid(True)
        
        # Plot 2: Drawdowns
        rolling_max = cum_returns.expanding().max()
        drawdowns = (cum_returns - rolling_max) / rolling_max * 100
        ax2.fill_between(drawdowns.index, drawdowns, 0, color='red', alpha=0.3)
        ax2.set_title('Drawdowns (%)')
        ax2.grid(True)
        
        # Plot 3: Monthly Returns
        monthly_returns = pd.Series([ret for _, ret in self.monthly_returns.values() if ret is not None])
        ax3.bar(range(len(monthly_returns)), monthly_returns)
        ax3.set_title('Monthly Returns (%)')
        ax3.grid(True)
        
        # Plot 4: Strategy Mode
        modes_df = pd.DataFrame(self.positions_history)
        ax4.plot(modes_df['date'], [1 if mode == 'offensive' else 0 for mode in modes_df['mode']])
        ax4.set_title('Strategy Mode (1=Offensive, 0=Defensive)')
        ax4.grid(True)
        
        plt.tight_layout()
        plt.show()

def download_with_retry(ticker, start_date, end_date, max_retries=5):
    for attempt in range(max_retries):
        try:
            buffer_start = start_date - timedelta(days=730)
            buffer_end = end_date + timedelta(days=5)
            
            df = yf.download(ticker, start=buffer_start, end=buffer_end, 
                           progress=False, interval='1d', auto_adjust=True)
            
            if not df.empty and len(df) > 100:
                date_range = pd.date_range(start=start_date, end=end_date, freq='B')
                df = df.reindex(date_range, method='ffill')
                return df
            
            time.sleep(2)
        except Exception as e:
            print(f"Retry {attempt + 1} for {ticker}")
            time.sleep(2)
    return None

if __name__ == '__main__':
    cerebro = bt.Cerebro()
    cerebro.addstrategy(KeyllyStrategy)
    cerebro.broker.setcommission(commission=0.001)
    cerebro.addobserver(bt.observers.Value)
    cerebro.addobserver(bt.observers.Trades)
    cerebro.addobserver(bt.observers.DrawDown)

    tickers = ['SPY', 'EFA', 'EEM', 'AGG', 'QQQ', 'PDBC', 'BIL', 'LQD']
    start_date = datetime(2015, 1, 1)
    end_date = datetime(2023, 11, 16)
    
    for ticker in tickers:
        print(f"Downloading data for {ticker}...")
        df = download_with_retry(ticker, start_date, end_date)
        if df is not None and not df.empty:
            data = bt.feeds.PandasData(dataname=df, fromdate=start_date, todate=end_date)
            cerebro.adddata(data, name=ticker)
        else:
            print(f"Failed to load {ticker}")
            exit(1)

    start_cash = 100000
    cerebro.broker.set_cash(start_cash)
    cerebro.addsizer(bt.sizers.PercentSizer)

    print(f"Starting Portfolio Value: {cerebro.broker.getvalue():.2f}")
    
    results = cerebro.run()
    strat = results[0]
    
    print("\nMonthly Returns:")
    for month_key, (date, ret) in sorted(strat.monthly_returns.items()):
        if ret is not None:
            print(f"{month_key}: {ret:.2f}%")
    
    print("\nAnnual Returns:")
    for year, ret in sorted(strat.annual_returns.items()):
        print(f"{year}: {ret:.2f}%")
    
    metrics = strat.calculate_performance_metrics()
    print("\nPerformance Metrics:")
    for metric, value in metrics.items():
        print(f"{metric}: {value:.2f}%")
    
    end_value = cerebro.broker.getvalue()
    total_return = ((end_value - start_cash) / start_cash) * 100
    years = (end_date - start_date).days / 365.25
    cagr = ((end_value / start_cash) ** (1/years) - 1) * 100
    
    print(f"\nFinal Portfolio Value: {end_value:.2f}")
    print(f"Total Return: {total_return:.2f}%")
    print(f"CAGR: {cagr:.2f}%")

    strat.plot_results()
    cerebro.plot(style='candlestick', volume=False)

Hi Wycliffes,

I do not have backtester installed in my environment and Claude says it could cause conflicts with some versions of Matplotlib that I don't wish to troubleshoot today. It seems like a good program that I need to install at some point. In any case I have not run this code:
,
Claude's comments and suggestions first:


Here are the key improvements I've made to help fix the returns calculation and overall strategy implementation:

1. Momentum Calculation Fixes:
- Properly implemented the 13612W momentum calculation with correct weights (12,4,2,1)
- Added dedicated momentum indicators using backtrader's built-in ROC (Rate of Change)
- Improved the canary asset check logic

2. Position Management:
- Added proper position sizing and rebalancing logic
- Implemented rebalance threshold to prevent excessive trading
- Better order management and tracking

3. Performance Tracking:
- Added built-in backtrader indicators for drawdown and monthly returns
- Improved portfolio value tracking
- Better calculation of performance metrics

4. Data Handling:
- Added proper monthly data handling using LastDayOfMonth indicator
- Better handling of lookback periods

5. Error Prevention:
- Added more robust error checking
- Better handling of edge cases
- Improved logging of trades and performance

To use this improved version:

1. Make sure you're using recent versions of the required packages
2. Replace the existing strategy class with this new version
3. When running the backtest, make sure to:
   - Include sufficient historical data (at least 13 months before start date)
   - Account for transaction costs
   - Use proper risk management settings

You might also want to add these parameters when creating the cerebro instance:

python
cerebro = bt.Cerebro()
cerebro.broker.set_cash(100000)
cerebro.broker.setcommission(commission=0.001)  # 0.1% commission
cerebro.addstrategy(BoldAssetStrategy)
cerebro.addobserver(bt.observers.Value)
cerebro.addobserver(bt.observers.DrawDown)
cerebro.addobserver(bt.observers.Trades)


This should help get your returns closer to the expected 11-12% range. The main issues in the original code were:
1. Incorrect momentum calculation weights
2. Improper handling of monthly rebalancing
3. Issues with performance tracking
4. Suboptimal position sizing

Would you like me to explain any of these improvements in more detail or help with implementing specific parts?

Full code:


import backtrader as bt
import pandas as pd
import numpy as np
import yfinance as yf
from datetime import datetime, timedelta
import time
from collections import OrderedDict
import matplotlib.pyplot as plt

class BoldAssetStrategy(bt.Strategy):
    params = dict(
        lookback_months=12,
        defensive_allocation=1/3,
        rebalance_threshold=0.01,
        momentum_lookback=[1, 3, 6, 12]  # Weights: 12, 4, 2, 1
    )

    def __init__(self):
        # Define asset universes
        self.canary = ['SPY', 'EFA', 'EEM', 'AGG']
        self.offensive = ['QQQ']
        self.defensive = ['AGG', 'LQD', 'PDBC']
        self.safe = 'BIL'
        
        # Store monthly data
        self.monthly_data = {asset: bt.indicators.LastDayOfMonth(self.getdatabyname(asset).close)
                           for asset in self.canary + self.offensive + self.defensive + [self.safe]}
        
        # Create momentum indicators
        self.momentum = {}
        for asset in self.canary + self.defensive:
            self.momentum[asset] = {}
            for period in self.params.momentum_lookback:
                self.momentum[asset][period] = bt.indicators.ROC(
                    self.monthly_data[asset],
                    period=period
                )
        
        # Portfolio tracking
        self.portfolio_start_value = self.broker.getvalue()
        self.orders = []  # Track pending orders
        self.positions_history = []
        self.monthly_returns = OrderedDict()
        self.current_weights = {d._name: 0.0 for d in self.datas}
        
        # Performance tracking
        self.portfolio_values = []
        self.drawdown = bt.indicators.DrawDown(self.broker.getvalue())
        self.monthly_ret = bt.indicators.Monthly_Return()

    def calculate_weighted_momentum(self, asset):
        """Calculate 13612W momentum with proper weights"""
        if not all(period in self.momentum[asset] for period in self.params.momentum_lookback):
            return -np.inf
            
        weights = [12, 4, 2, 1]  # Corresponding to 1,3,6,12 month lookbacks
        weighted_sum = 0
        
        for period, weight in zip(self.params.momentum_lookback, weights):
            weighted_sum += weight * self.momentum[asset][period][0]
            
        return weighted_sum

    def check_canary(self):
        """Check if all canary assets have positive momentum"""
        canary_signals = {}
        for asset in self.canary:
            momentum = self.calculate_weighted_momentum(asset)
            canary_signals[asset] = momentum > 0
            
        return all(canary_signals.values()), canary_signals

    def select_defensive_assets(self):
        """Select the best performing defensive assets"""
        defensive_scores = {asset: self.calculate_weighted_momentum(asset) 
                          for asset in self.defensive}
        return sorted(defensive_scores.items(), key=lambda x: x[1], reverse=True)

    def next(self):
        # Only trade on the last day of the month
        if not bt.indicators.LastDayOfMonth(self.data0):
            return
            
        # Calculate current portfolio value and store it
        portfolio_value = self.broker.getvalue()
        self.portfolio_values.append((self.data0.datetime.date(0), portfolio_value))
        
        # Check if we should be offensive or defensive
        canary_positive, canary_signals = self.check_canary()
        
        # Calculate target weights
        target_weights = {d._name: 0.0 for d in self.datas}
        
        if canary_positive:
            # Offensive allocation: 100% QQQ
            target_weights['QQQ'] = 1.0
            mode = 'offensive'
        else:
            # Defensive allocation: 1/3 best defensive asset + 2/3 cash
            defensive_ranked = self.select_defensive_assets()
            best_defensive = defensive_ranked[0][0]
            target_weights[best_defensive] = self.params.defensive_allocation
            target_weights[self.safe] = 1 - self.params.defensive_allocation
            mode = 'defensive'
        
        # Execute trades if weights have changed significantly
        for data in self.datas:
            current_weight = self.current_weights[data._name]
            target_weight = target_weights[data._name]
            
            if abs(target_weight - current_weight) > self.params.rebalance_threshold:
                target_value = portfolio_value * target_weight
                target_shares = int(target_value / data.close[0])
                order = self.order_target_size(data, target_shares)
                self.orders.append(order)
        
        # Update current weights and record positions
        self.current_weights = target_weights.copy()
        self.positions_history.append({
            'date': self.data0.datetime.date(0),
            'mode': mode,
            'weights': target_weights.copy(),
            'portfolio_value': portfolio_value
        })

    def notify_order(self, order):
        if order.status in [order.Completed]:
            if order in self.orders:
                self.orders.remove(order)
            
            self.log(f"Order completed: {order.data._name} - "
                    f"Size: {order.executed.size}, "
                    f"Price: {order.executed.price:.2f}, "
                    f"Cost: {order.executed.value:.2f}, "
                    f"Comm: {order.executed.comm:.2f}")

    def stop(self):
        """Calculate and display final performance metrics"""
        portfolio_df = pd.DataFrame(self.portfolio_values, columns=['Date', 'Value'])
        portfolio_df.set_index('Date', inplace=True)
        
        # Calculate returns and metrics
        returns = portfolio_df['Value'].pct_change()
        cum_returns = (1 + returns).cumprod()
        
        # Performance metrics
        ann_return = (1 + returns).prod() ** (252/len(returns)) - 1
        ann_vol = returns.std() * np.sqrt(252)
        sharpe = ann_return / ann_vol if ann_vol != 0 else 0
        max_drawdown = self.drawdown.maxdrawdown[0]
        
        # Print results
        self.log(f"\nStrategy Performance Metrics:")
        self.log(f"Annual Return: {ann_return*100:.2f}%")
        self.log(f"Annual Volatility: {ann_vol*100:.2f}%")
        self.log(f"Sharpe Ratio: {sharpe:.2f}")
        self.log(f"Max Drawdown: {max_drawdown*100:.2f}%")
        self.log(f"Final Portfolio Value: {self.broker.getvalue():.2f}")

    def log(self, txt, dt=None):
        dt = dt or self.data0.datetime.date(0)
        print(f'{dt.isoformat()} - {txt}')

"Thank you, Jrinne!! :slight_smile:

I tried it in Visual Studio, but it didn't work right away. Now it does, but the return is still very low. According to the strategy described in the link above, it should yield between 15-18%, but I'm only getting a return of 8% back to 2014. That's why I'm wondering if the strategy is being implemented incorrectly or if the calculation of the return is not right?!

Here are my code:

import backtrader as bt
import pandas as pd
import numpy as np
import yfinance as yf
from datetime import datetime

class BoldAssetStrategy(bt.Strategy):
    params = dict(
        lookback_months=12,
        defensive_allocation=1 / 3,
        rebalance_threshold=0.01,
        momentum_lookback=[1, 3, 6, 12]
    )

    def __init__(self):
        self.canary = ['SPY', 'EFA', 'EEM', 'AGG']
        self.offensive = ['QQQ']
        self.defensive = ['AGG', 'LQD', 'PDBC']
        self.safe = 'BIL'

        self.momentum = {
            asset: {
                period: bt.indicators.ROC(self.datas[idx].close, period=period, plot=False)
                for period in self.params.momentum_lookback
            }
            for idx, asset in enumerate(self.canary + self.defensive)
        }

        self.portfolio_start_value = self.broker.getvalue()
        self.orders = []
        self.positions_history = []
        self.current_weights = {data._name: 0.0 for data in self.datas}
        self.portfolio_values = []

    def calculate_weighted_momentum(self, asset_index):
        asset_name = self.datas[asset_index]._name
        if asset_name not in self.momentum:
            return -np.inf

        weights = [12, 4, 2, 1]
        weighted_sum = sum(
            weight * self.momentum[asset_name][period][0]
            for period, weight in zip(self.params.momentum_lookback, weights)
        )
        return weighted_sum

    def check_canary(self):
        canary_signals = {
            asset: self.calculate_weighted_momentum(idx) > 0
            for idx, asset in enumerate(self.canary)
        }
        return all(canary_signals.values()), canary_signals

    def select_defensive_assets(self):
        defensive_scores = {
            self.datas[idx]._name: self.calculate_weighted_momentum(idx)
            for idx, asset in enumerate(self.defensive)
        }
        return sorted(defensive_scores.items(), key=lambda x: x[1], reverse=True)

    def next(self):
        # Only trade on the last day of the month
        current_date = self.data.datetime.date(0)
        if current_date.day != pd.Timestamp(current_date).days_in_month:
            return

        portfolio_value = self.broker.getvalue()
        self.portfolio_values.append((current_date, portfolio_value))

        # Debugging: Print portfolio values as they're added
        print(f"Portfolio value recorded for {current_date}: {portfolio_value}")

        canary_positive, _ = self.check_canary()

        target_weights = {data._name: 0.0 for data in self.datas}
        if canary_positive:
            target_weights['QQQ'] = 1.0
        else:
            defensive_ranked = self.select_defensive_assets()
            best_defensive = defensive_ranked[0][0]
            target_weights[best_defensive] = self.params.defensive_allocation
            target_weights[self.safe] = 1 - self.params.defensive_allocation

        for data in self.datas:
            current_weight = self.current_weights[data._name]
            target_weight = target_weights[data._name]

            if abs(target_weight - current_weight) > self.params.rebalance_threshold:
                target_value = portfolio_value * target_weight
                target_shares = int(target_value / data.close[0])
                self.order_target_size(data, target_shares)

        self.current_weights = target_weights.copy()
        self.positions_history.append({
            'date': current_date,
            'weights': target_weights.copy(),
            'portfolio_value': portfolio_value,
        })

    def stop(self):
        portfolio_df = pd.DataFrame(self.portfolio_values, columns=['Date', 'Value'])
        portfolio_df.set_index('Date', inplace=True)

        # Debugging: Print entire portfolio value history
        print("\nPortfolio Values:\n", portfolio_df)

        if portfolio_df.empty:
            print("Error: Portfolio values are empty. No trades executed.")
            return

        returns = portfolio_df['Value'].pct_change().dropna()
        if returns.empty:
            print("Error: No returns data to compute metrics.")
            return

        cum_returns = (1 + returns).cumprod()
        ann_return = (1 + cum_returns.iloc[-1]) ** (1 / ((cum_returns.index[-1] - cum_returns.index[0]).days / 365)) - 1
        ann_vol = returns.std() * np.sqrt(252)
        sharpe = ann_return / ann_vol if ann_vol != 0 else 0

        self.log(f"\nStrategy Performance Metrics:")
        self.log(f"Annual Return: {ann_return * 100:.2f}%")
        self.log(f"Annual Volatility: {ann_vol * 100:.2f}%")
        self.log(f"Sharpe Ratio: {sharpe:.2f}")
        self.log(f"Final Portfolio Value: {self.broker.getvalue():.2f}")

    def log(self, txt, dt=None):
        dt = dt or self.data.datetime.date(0)
        print(f'{dt.isoformat()} - {txt}')


if __name__ == "__main__":
    cerebro = bt.Cerebro()

    tickers = ['SPY', 'EFA', 'EEM', 'AGG', 'QQQ', 'LQD', 'PDBC', 'BIL']
    for ticker in tickers:
        data = bt.feeds.PandasData(
            dataname=yf.download(ticker, start='2010-01-01', end='2024-11-01', progress=False)
        )
        cerebro.adddata(data, name=ticker)

    cerebro.addstrategy(BoldAssetStrategy)
    cerebro.broker.set_cash(100000)
    cerebro.run()
    cerebro.plot()

1 Like

Allocate Smartly’s implementation of Wouter Keller’s Bold Asset Allocation – Aggressive

Hasn’t been 15-18% over the last 10 years. From November 2014 till present, they show a

9.33% annual gain. So your 8% isn’t too far off. They (Allocate Smartly) benchmark the

strategy against a 60/40 SP500/IEF intermediate-term Treasuries which had an annual gain of

8.4% over the same 10 year period. I’ve had a portion of what I call my safer investments in

BAA-A for the last couple of years.

1 Like

Yes, you are correct, and thank you for your input. However, I have added a function to display the complete transaction picture along with the returns graph. This illustrates, among other things:

  • Several transactions are significantly at odds with the model's expected choices.
  • Some months are omitted from the transactions.
  • Transactions occur on days other than the last day of the month.

I am somewhat surprised by the limited capacity of both the paid version and ChatGPT and Claude Sonnet to deliver complete and functioning code. This is not a particularly advanced system, yet it presents several options that incorrectly calculate based on momentum, overlook certain months, and blend offensive and defensive ETFs.

I will try some other solutions to see if I can get to work.

Additionally, I am also a member of AllocateSmartly and have used this strategy, among others. I am very satisfied with their services, but it would be great to have the strategy in Python to analyze the transactions in more detail and assess how they align—or hedge—my stock portfolio.

1 Like

I’m one of the older retiree’s and a little more conservative than most P123 investors.

So I too have also used AllocateSmartly’s TAA system combine strategies to minimize volitility. Currently using combined strategy of Bold Asset Allocation Aggressive and Risk Premium Value – Best Value. Each strategy does well on their own and are not too correlated. CAGR of the strategy for last 10 years would have been 9.6% if I had followed the strategy alone. However instead of the ETFs they select I select individual equities using screens to improve the results.

Years ago, I wrote programs to test several of the TAA strategies. I eventually became convinced that the individuals at AllocateSmartly were better at it and more reliable than I was. If you do it yourself, make sure you do daily starts. If the differences between daily starts and single end of month results are significant the system is based on luck.

1 Like

I wrote a program that auto-sorts AllocateSmartly portfolios based on a probabilistic Sharpe ratio and then, figures out current holdings, and creates a meta portfolio.

def probabilistic_sharpe_ratio(sharpe_ratio, bench_sharpe_ratio, num_obs, skewness, kurtosis):
    """
    Calculates the Probabilistic Sharpe Ratio
    
    Parameters:
    - sharpe_ratio: Sharpe ratio of the strategy
    - bench_sharpe_ratio: Sharpe ratio of the benchmark
    - num_obs: Number of observations
    - skewness: Skewness of the strategy returns
    - kurtosis: Kurtosis of the strategy returns
    
    Returns:
    - psr: Probabilistic Sharpe Ratio
    """
    
    sr_diff = sharpe_ratio - bench_sharpe_ratio
    sr_vol = standard_deviation_sharpe_ratio(sharpe_ratio, num_obs, skewness, kurtosis)
    psr = norm.cdf(sr_diff / sr_vol)
    
    return psr
# Calculate Standard Deviation of Sharpe Ratio (Std Dev SR)
def standard_deviation_sharpe_ratio(sharpe_ratio, num_obs, skewness=0, kurtosis=0):
    """Estimates standard Deviation of Sharpe Ratio
    
    Parameters:
    - sharpe_ratio: Sharpe ratio of the strategy
    - bench_sharpe_ratio: Sharpe ratio of the benchmark
    - num_obs: Number of observations
    - skewness: Skewness of the strategy returns (default 0)
    - kurtosis: Kurtosis of the strategy returns (default 3)
    
    Returns:
    - std_dev: Standard Deviation of Sharpe Ratio
    """
    return np.sqrt(
        (1 - skewness*sharpe_ratio + 
         (kurtosis-1)/4*sharpe_ratio**2
        ) / (num_obs-1)
    )

The idea is here: Probabilistic Sharpe Ratio | Quantdare

The goal is to reduce portfolio turnover where there are so many possible TAA options, along with automating selection.

I then volatility weight the underlying portfolios selected:

paul-chois-dividend-and-growth-allocation: 0.07983245910371625
philosophical-economics-growth-trend-timing: 0.08702635368476425
wouter-kellers-bold-asset-allocation-aggressive: 0.12617959675250104
financial-mentors-optimum-3: 0.13812108895642272
keller-and-keunings-hybrid-asset-allocation: 0.15432443776053512
white-and-haghanis-excess-earnings-yield-dynamic-with-momentum: 0.12263524050156198
GPM3: 0.16373291720089497
wouter-kellers-lethargic-asset-allocation: 0.12814790603960366

The returns are lower than what you're targetting, but the results seem pretty safe and consistent.

Here is the final selection:

Total ETF Allocations as Percentages of Account Balance:
                                      ETF Allocation (%)
0                  Emerging Markets (VWO)             4%
1                       Real Estate (VNQ)             9%
2                           S&P 500 (VOO)            18%
3                      Russell 2000 (IWM)             9%
4                              Gold (IAU)            13%
5                        Nasdaq 100 (QQQ)            13%
6  Investment Grade Corporate Bonds (LQD)             4%
7                              TIPS (TIP)             7%
8   Intermediate-Term US Treasuries (IEF)             3%
9                Russell 1000 Value (IWD)             3%

The remainder is kept in cash.

I have been running for over a year "out of sample" and results are matching the backtest so far.

The idea is sort of to have a best-of snapshot of TAA and pick up the current state of risk-on/risk-off.

3 Likes

This approach is similar to Logical-Invest.com and it works. The difference is Logical invest uses a multistrategy approach that ranks each strategy. Each strategy has a theme like "Hedge" = GLD or TLT. The ranking engine will pick GLD or TLT based on your parameters. Then it looks at all the other strategies and applies the same type of logic. I was hoping P123 would provide something similar but I don't think many people are interested.

Cheers,
MV

1 Like

Hi CMS,

I find what you are doing EXTREMELY interesting.

I wonder what look-back period you use or num_obs in your Python code.

Also, I understand you are using the inverse volatility for your weighting. Have you considered using (the probability that the Sharpe ratio is above a certain threshold)/variance for the weighting? Seems like the probabilistic Sharpe ratio would be ideal for such an algorithm.

That would be analogous to the original post in this thread where the probability of a holding performing above a certain threshold using the probabilistic Sharpe ratio is substituted for the probability of an asset performing above SPY as determined by bootstrapping used in the initial post. Both methods would then simplify to: probability (using different methods) divided by the variance.

I am not claiming to know whether that is better than simple inverse variance weighting, and probably I should have checked that myself in the original post. But I would be interested in finding out.

One fine point where there could be a difference in algorithms is whether one decides to include an asset in the portfolio if the probability of that asset performing better than the benchmark is less than 0.5 (using either method to determine the probability).

And just a note on why I like this so much: The t-score is the Sharpe ratio * sqrt(num_obs) assuming a normal distribution as you know. So your method gives an exact statistical method for deterring which ETFs have been performing better when the distribution is not normal. Bootstrapping may do the same thing. And if not used for weighting, either method could still be used to determine inclusion in the portfolio.

And Claude 3 agrees with me on this which may be an advantage to either method: "...using probability/variance acts as a form of regularization."

Thanks!

Edit to my original post: If you are going to use Kelly betting with probabilities the bet size would be (2p-1)/(p-1). You would still want to divide by variance to make the magnitude of each bet (the bet on each portfolio) the same which is analogous to beta weighting of positions sizes. So the formula (2p-1)/((1-p)*variance) would be correct for Kelly betting size with p = the probability. If you use p = the probability of a positive return then negative values for this equation (i.e., p < 0.5) for this value would suggest a possible benefit for shorting a position making it consistent, I believe.

I think this is now logically and mathematically consistent with little evidence presented that it would actually work (other than my original post with an incorrect formula).. :slightly_smiling_face:

That change and a longer sliding window improves returns and turnover with this string of ETFs: ["XLE", "XLU", "XLK", "XLB", "XLP", "XLY", "XLI", "XLV", "XLF", "TLT", "GLD"]. I have not tried it with AllocateSmartly portfolios but I would expect better metrics using a meta portfolio of selected high-performance strategies.

Jim

1 Like

Wow, that is a lot to unpack! Thank you!

I have used 96-months. I didn’t take notes, but I remember longer periods working better. I started comparing 12, 24, 36, and 48 - but it just seemed longer worked better. I wanted a cut-off, and 8-10 years seemed good to try and deal with the possibility of market change. I am using monthly returns.

"Also, I understand you are using the inverse volatility for your weighting...."

I have not, this is a very interesting idea. I was just using PSR as a filter to reduce turnover, so the benchmark was always another portfolio in the system. So if we want to see if BAA-Aggressive is better than GTAA 13 or whatever is currently in the system, I wanted high confidence before replacing an existing system.

With first attempting weighting of benchmark sharpe I used 0 and then 1, but was having identical weightings. I think this is because the probability of beating that given benchmark was so high. I am not testing median of all models.

"One fine point where there could be a difference in algorithms ..."

That is interesting, but with my current setup such a filter didn’t seem applicable as I was requiring the 95% confidence for system replacement. I believe that is implicit in the method I’ve used so far though.

"And just a note on why I like this so much..."

That would be very interesting to test inside a system and I attempted to test as well on the system selector.

"If you are going to use Kelly betting with probabilities..."

Okay, I tried to implement this - but not shorting as I can’t really go short a system. I could theoretically subtract the suggested weightings from the final allocation but that’s getting more complex. It would take me a lot more work to implement this as a complete TAA system as my current codebase for direct strategy implementation is very bad and quite a patchwork.

...

Here is the logic update I implemented:

                if weighting_method == 'volatility':
                    # Volatility weighting logic
                    inverse_vol_weights = 1 / volatilities[top_models]
                    normalized_weights = inverse_vol_weights / inverse_vol_weights.sum()
                    model_weights = {model: normalized_weights[model] for model in top_models}

                    # Update model leverages with normalized weights
                    model_leverages = {model: normalized_weights[model] for model in top_models}

                elif weighting_method == 'psr':
                    # PSR-based weighting logic
                    psr_values = {model: probabilistic_sharpe_ratio(
                                    sharpe_ratios[model], 
                                    benchmark_sharpe,  # Set benchmark to 0 to calculate raw probabilities
                                    len(window), 
                                    skew(window[model]), 
                                    kurtosis(window[model], fisher=False)
                                ) for model in top_models}

                    # Combine PSR values with inverse variance
                    psr_weights = {model: psr_values[model] / volatilities[model] for model in top_models}

                    # Normalize weights
                    total_weight = sum(psr_weights.values())
                    if total_weight > 0:
                        model_weights = {model: psr_weights[model] / total_weight for model in top_models}
                    else:
                        model_weights = {model: 1.0 / len(top_models) for model in top_models}  # Fallback to equal weighting

                    # Update model leverages with PSR weights
                    model_leverages = model_weights


                elif weighting_method == 'kelly':
                    # Kelly-based weighting logic
                    kelly_weights = {}
                    for model in top_models:
                        psr = probabilistic_sharpe_ratio(
                            sharpe_ratios[model], 
                            benchmark_sharpe,  # Use 0 if no explicit benchmark is needed
                            len(window), 
                            skew(window[model]), 
                            kurtosis(window[model], fisher=False)
                        )
                        # Handle edge cases for P and volatility
                        if 0.5 < psr < 1 and volatilities[model] > 1e-6:  # Valid P and volatility
                            kelly_weights[model] = (2 * psr - 1) / ((1 - psr) * volatilities[model])
                        else:
                            kelly_weights[model] = 0  # Set to 0 for invalid cases

                    total_weight = sum(kelly_weights.values())
                    if total_weight > 0:
                        model_weights = {model: kelly_weights[model] / total_weight for model in top_models}
                    else:
                        # Fall back to equal weighting if no valid weights
                        model_weights = {model: 1.0 / len(top_models) for model in top_models}

                    # Update model leverages with Kelly weights
                    model_leverages = model_weights

                else:
                    # Default to equal weighting
                    model_weights = {model: 1.0 / N for model in top_models}

                    # Update model leverages with equal weights
                    model_leverages = {model: 1.0 / N for model in top_models}

Edit:

I'm not sure why my default portfolio statistics changed a bit. My codebase isn't very clean and very patchwork. As far as I can tell the logic is the same, but need to dig in more.

1 Like

I had a minimum volatility floor and some custom portfolios in the mix. This was because I found many of the TAA portfolios had too low of volatility and returns, though some might find this a benefit if they want to use it as a bond-like allocation. I removed those here

I also reduced the portfolios to 6.

In this case you can see a clear benefit to the Kelly weighting system in enhancing the CAGR and achieving higher volatility, though equal also works well as it overcomes inverse volatility's overweighting of low vol portfolios. The PSR versus the median also works well here.

However it gets concentrated on variations of the same model:

['financial-mentors-optimum-3', 'wouter-kellers-bold-asset-allocation-aggressive', 'wouter-kellers-bold-asset-allocation', 'paul-chois-dividend-and-growth-allocation', 'keller-and-keunings-hybrid-asset-allocation', 'keller-and-keunings-hybrid-asset-allocation-simple']

We can filter out B&H, bond portfolios, and duplicate variations though

    # filtering to remove redundant strategies & B&H
    'meb-fabers-global-tactical-asset-allocation-13-gtaa-13',
    'meb-fabers-global-tactical-asset-allocation-5-gtaa-5',
    'allocate-smartlys-meta-strategy',
    'permanent-portfolio',
    'minimum-correlation-portfolio',
    "sell-in-may-halloween-indicator",
    "meb-fabers-ivy-portfolio",
    "meb-fabers-trinity-portfolio-lite",
    "paul-novells-tactical-bond-strategy",
    "paul-novells-bond-comp",
    "harry-brownes-permanent-portfolio",
    "portfoliocharts-golden-butterfly",
    "us-equal-risk-contribution",
    "paul-novells-bond-ui1",

    # # #Excluded because daily or trades at different times
    'grzegorz-links-global-growth-cycle', #trades on the 15th.
    'dick-stokens-active-combined-asset-aca', #trades daily

    # #Duplicates!
    'keller-and-keunings-hybrid-asset-allocation-simple',
    'wouter-kellers-bold-asset-allocation'

1 Like

Kelly looks more interesting if I use an expanding window of all data. That would provide more certainty to the Sharpe ratios and possible outperformance.

1 Like

I slightly modified my model.

Now it is based on RS, with three factors:

  • kelly formula, 1 yr and 1 month (80%)
  • correlation with spy - lower better and vix - higher better (10%)
  • volatility (10%)

I also added three broad commodity etfs: Ticker("DBB, DBA, DBE").

It is still not optimal I believe.. Feel free to improve the model.

https://www.portfolio123.com/port_summary.jsp?portid=1833892

2 Likes

this is fascinating. i did not realize you could even do somehting like this!

Why do you choose to not use any intermediate or short term bonds in the universe?