Overall Statistics
Total Trades
540
Average Win
0.46%
Average Loss
-0.70%
Compounding Annual Return
7.032%
Drawdown
21.600%
Expectancy
0.242
Net Profit
69.334%
Sharpe Ratio
0.384
Probabilistic Sharpe Ratio
7.356%
Loss Rate
25%
Win Rate
75%
Profit-Loss Ratio
0.66
Alpha
0.003
Beta
0.415
Annual Standard Deviation
0.091
Annual Variance
0.008
Information Ratio
-0.378
Tracking Error
0.111
Treynor Ratio
0.084
Total Fees
$881.33
Estimated Strategy Capacity
$17000000.00
Lowest Capacity Asset
ISI SVL6LMOM67AD
Portfolio Turnover
1.39%
#region imports
from AlgorithmImports import *
from collections import deque
#endregion

class ClassicMomentum(PythonIndicator):
    # Momentum measured as p11 / p0 - 1 (excluding last month, or in this case generalized to exclude last 1/12 of period)
    def __init__(self, name, period):
        self.Name = name
        self.WarmUpPeriod = period
        self.Time = datetime.min
        self.Value = 0
        self.queue = deque(maxlen=period)

    def Update(self, input: BaseData) -> bool:
        self.queue.appendleft(input.Value)
        self.Time = input.Time
        self.Value =  (self.queue[-int((1/12)*len(self.queue))] / self.queue[-1]) - 1
        return len(self.queue) == self.queue.maxlen

class SimpleMomentum(PythonIndicator):
    # Momentum measured as p11 / p0 - 1
    def __init__(self, name, period):
        self.Name = name
        self.WarmUpPeriod = period
        self.Time = datetime.min
        self.Value = 0
        self.queue = deque(maxlen=period)

    def Update(self, input: BaseData) -> bool:
        self.queue.appendleft(input.Value)
        self.Time = input.Time
        self.Value =  (self.queue[0] / self.queue[-1]) - 1
        return len(self.queue) == self.queue.maxlen

class ClassicVolatility(PythonIndicator):
    # Simple standard deviation of log returns
    def __init__(self, name, period):
        self.Name = name
        self.WarmUpPeriod = period
        self.Time = datetime.min
        self.Value = 0
        self.window = RollingWindow[float](period)

    def Update(self, input: BaseData) -> bool:
        self.window.Add(input.Value)
        self.Time = input.Time
        self.Value =  np.std([x for x in self.window])
        return self.window.IsReady
#region imports
from AlgorithmImports import *
import riskfolio as rp
#endregion

import numpy as np
import pandas as pd
from datetime import datetime, timedelta
import scipy
from scipy.stats import linregress

from indicators import SimpleMomentum, ClassicVolatility

class AdaptiveAssetAllocation(QCAlgorithm):

    def Initialize(self):
        self.SetCash(100000)
        self.SetStartDate(2016, 1, 1) # RWX only start end of 12/006
        self.SetEndDate(2023, 10, 1)
        self.Settings.MinimumOrderMarginPortfolioPercentage = 0.  

        # Hyper-parameters #
        ####################
        self.symbols = ['ITOT', 'EWJ', 'VNQ', 'RWX', 'IEF', 'DBC', 'VGK', 'EEM', 'TLT', 'GLD']
        self.allocation_method = 'pos_sizing_momentum'  # change to any of the allocation methods
        self.volatility_lookback = 3*21
        self.momentum_lookback = 6*21
        self.top_n_momentum = 5
        ####################

        self.volatility_ind = {}
        self.momentum_ind = {}

        for i, asset in enumerate(self.symbols):
            self.AddEquity(asset, Resolution.Daily)
            
            mom_indicator = SimpleMomentum(f"mom_{asset}", period=self.momentum_lookback)
            self.RegisterIndicator(asset, 
                mom_indicator, 
                TradeBarConsolidator(1))
            self.momentum_ind[asset] = mom_indicator

            vol_indicator = ClassicVolatility(f"vol_{asset}", period=self.volatility_lookback)
            self.RegisterIndicator(asset, 
                vol_indicator, 
                TradeBarConsolidator(1))
            self.volatility_ind[asset] = vol_indicator

        self.Schedule.On(self.DateRules.MonthStart(self.symbols[0], daysOffset=0),
                         self.TimeRules.BeforeMarketClose(self.symbols[0], minutesBeforeClose=0),
                         self.rebalance)

        self.SetWarmUp(max(self.volatility_lookback, self.momentum_lookback))
        self.weights = None

    def OnData(self, slice: Slice) -> None:

        if self.IsWarmingUp: return

        # Plot the current portfolio weights
        if self.weights is not None:
            for sym in self.weights:
                self.Plot("Portfolio Weights", sym, self.weights[sym])

        # when trading live, reset indicators on splits & dividends
        # if data.Splits.ContainsKey(self.symbol) or data.Dividends.ContainsKey(self.symbol):

    def rebalance(self):
        if self.IsWarmingUp: return
        # get all weights
        self.weights = getattr(self, self.allocation_method)()
        self.Log(self.weights)
        self.SetHoldings([PortfolioTarget(asset, self.weights[asset]) for asset in self.weights], True)
    

    ########################
    ## Allocation methods ##
    ########################

    def pos_sizing_sixtyfortybench(self):
        # Track US 6040 benchmark 
        return {'ITOT': 0.6,
                'TLT': 0.4}

    def pos_sizing_equal_weight(self):
        # Buy all assets, equal weighted
        return {k: ((1)/len(self.symbols))  for k in self.symbols}
    
    def pos_sizing_inverse_volatility(self):
        # Buy all assets, weight them inversely proportional to their volatility
        total_inv_vol = sum([1/self.volatility_ind[sym].Value for sym in self.symbols if self.volatility_ind[sym].IsReady])
        return {sym: math.floor(100* (1/self.volatility_ind[sym].Value) / (total_inv_vol))/100 for sym in self.symbols if self.volatility_ind[sym].IsReady}

    def pos_sizing_momentum(self):
        # Buy the top n momentum assets, equal weighted
        top = self._get_top_momentum(self.top_n_momentum)
        return {sym: 1/self.top_n_momentum if sym in top else 0 for sym in self.symbols }

    def pos_sizing_inverse_volatility_momentum(self):
        # Buy the top n momentum assets, weight them inversely proportional to their volatility
        top = self._get_top_momentum(self.top_n_momentum)

        total_inv_vol = sum([1/self.volatility_ind[sym].Value for sym in top.index])
        return {sym: math.floor(100* (1/self.volatility_ind[sym].Value) / (total_inv_vol))/100 if sym in top else 0 for sym in self.symbols }

    def pos_sizing_minimum_variance(self):
        # Buy all assets, weight them via minvar optimization
        symbols = self.symbols
        ret = self.History(symbols, self.volatility_lookback, Resolution.Daily).unstack(level=0).close.pct_change()[1:]
        port = rp.Portfolio(returns=ret)

        port.assets_stats(method_mu='hist', method_cov='hist', d=0.94)
        w = port.optimization(model='Classic', rm='MV', obj='MinRisk', rf=0, l=0, hist=True)
        allocation = pd.Series([math.floor(x*100)/100 for x in w.values.flatten()], index=ret.columns).to_dict()
        return allocation

    def pos_sizing_momentum_minimum_variance(self):
        # Buy the top n momentum assets, weight them via minvar optimization
        symbols = self._get_top_momentum(self.top_n_momentum)

        ret = self.History(symbols, self.volatility_lookback, Resolution.Daily).unstack(level=0).close.pct_change()[1:]
        port = rp.Portfolio(returns=ret)
        port.assets_stats(method_mu='hist', method_cov='hist', d=0.94)
        w = port.optimization(model='Classic', rm='MV', obj='MinRisk', rf=0, l=0, hist=True)
        allocation = pd.Series([math.floor(x*100)/100 for x in w.values.flatten()], index=ret.columns).to_dict()
        return allocation

    def _get_top_momentum(self, n):
        # returns list of n symbols with biggest momentum
        mom_dict = {k: v for k, v in self.momentum_ind.items() if v.IsReady}
        sorted_signals = pd.Series(mom_dict).sort_values(ascending = False)
        symbols = sorted_signals[:self.top_n_momentum].index.tolist()
        return symbols