A Novel Way to Control Risks

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)