Overall Statistics
Total Orders
32126
Average Win
0.02%
Average Loss
-0.03%
Compounding Annual Return
1.190%
Drawdown
19.200%
Expectancy
0.027
Start Equity
100000
End Equity
119326.80
Net Profit
19.327%
Sharpe Ratio
-0.135
Sortino Ratio
-0.145
Probabilistic Sharpe Ratio
0.005%
Loss Rate
46%
Win Rate
54%
Profit-Loss Ratio
0.90
Alpha
-0.009
Beta
0.015
Annual Standard Deviation
0.054
Annual Variance
0.003
Information Ratio
-0.645
Tracking Error
0.15
Treynor Ratio
-0.494
Total Fees
$386.14
Estimated Strategy Capacity
$7200000.00
Lowest Capacity Asset
RUSH R735QTJ8XC9X
Portfolio Turnover
1.31%
#region imports
from AlgorithmImports import *
#endregion

class SymbolData():
    def __init__(self, daily_period: int, monthly_period: int) -> None:
        self._daily_price: RollingWindow = RollingWindow[float](daily_period)
        self._monthly_price: RollingWindow = RollingWindow[float](monthly_period)
    
    def update_daily_price(self, price: float) -> None:
        self._daily_price.add(price)

    def update_monthly_price(self, price: float) -> None:
        self._monthly_price.add(price)

    def get_vol(self) -> float:
        daily_returns: np.ndarray = pd.Series(list(self._daily_price)[::-1]).pct_change().values[1:]
        return np.std(daily_returns) * np.sqrt(252)

    def get_momentum(self) -> float:
        # skip latest month
        return self._monthly_price[1] / self._monthly_price[self._monthly_price.count - 1] - 1

    def is_ready(self) -> bool:
        return self._daily_price.is_ready and self._monthly_price.is_ready

# Custom fee model.
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Security.Price * parameters.Order.AbsoluteQuantity * 0.00005
        return OrderFee(CashAmount(fee, "USD"))
# https://quantpedia.com/strategies/leveraging-the-low-volatility-effect/
# 
# The investment universe for this strategy consists of the top 1,000 U.S. stocks by market capitalization.
# Coarse Selection: Individual instruments are selected based on historical volatility, momentum, and value characteristics. Initially, the strategy screens for the 
# 500 stocks with the lowest 3-year historical volatility. From these, stocks are further evaluated and ranked based on their 12-1 month price momentum and net payout
# yield (measuring value). The top 100 stocks with the highest combined momentum and value scores are selected for inclusion in the portfolio.
# (Data Sources: U.S. bond data and credit spreads are sourced from the FRED database, with bond returns calculated using the 10-year government bond yield. The equity 
# and 1-month T-bill rate are from the Kenneth French data library, and the low-volatility data are from the Robeco website.)
# Follow the approach described on pg. 9: Fifth case: Downside protection: construct a portfolio with a 30% long position in low-volatility stocks combined with a -50% 
# short position in speculative stocks. (The result shall be portfolio with a downside beta of around -0.5 and generally strong defensive properties versus equities.)
# Trading Rules:
# Buy rules involve selecting 100 stocks from the low-volatility subset that rank highest combined with momentum and value scores using leverage 0.3.
# Stocks selected for shorting are the 'speculative stocks' described by Blitz and Van Vliet (2018), characterized by high volatility, weak net payout yield, and poor 
# 12-1-month momentum, the worst 100 stocks with leverage of 0.5.
# Sell and (buy to) cover rules are applied during monthly rebalancing when stocks that no longer meet the criteria are removed from the portfolio.
# (Left 0.2 capital can be in risk-free assets or cash.)
# The portfolio is constructed by equal-weighting the top and bottom 100 selected stocks (all 200 in equal weight), ensuring diversification and minimizing concentration 
# risk. The strategy includes monthly rebalancing to maintain alignment with the selection criteria.
# 
# Implementation changes:
#	- BIL ETF is used as risk free asset.
#   - long stocks use 0.5 leverage and short stocks use 0.3 leverage as we suspect that there is typo in the paper

# region imports
from AlgorithmImports import *
import data_tools
from numpy import isnan
# endregion

class LeveragingTheLowVolatilityEffect(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2010, 1, 1)
        self.set_cash(100_000)

        self._excluded_tickers: List[str] = ['IIGP', 'FHRI', 'HRC', 'GTNR', 'CLRO', 'RELM', 'SKH']
        self._exchange_codes: List[str] = ['NYS', 'NAS', 'ASE']

        leverage: int = 4
        self._vol_period: int = 3 * 252
        self._mom_period: int = 12
        self._short_weight: float = .3
        self._long_weight: float = .5
        self._risk_free_weight: float = 1 - (self._short_weight + self._long_weight)

        self._risk_free_asset: Symbol = self.add_equity('BIL', Resolution.DAILY).symbol

        market: Symbol = self.add_equity('SPY', Resolution.DAILY).symbol

        self._stock_count: int = 100
        self._fundamental_count: int = 1_000
        self._fundamental_sorting_key = lambda x: x.market_cap

        self._data: Dict[Symbol, data_tools.SymbolData] = {}
        self._weight: Dict[Symbol, float] = {}

        self._selection_flag: bool = False
        self._rebalance_flag: bool = False
        self.universe_settings.leverage = leverage
        self.universe_settings.resolution = Resolution.DAILY
        self.add_universe(self.fundamental_selection_function)
        self.settings.daily_precise_end_time = False
        self.settings.minimum_order_margin_portfolio_percentage = 0.

        self.schedule.on(
            self.date_rules.month_start(market),
            self.time_rules.after_market_open(market),
            self.selection
        )

    def on_securities_changed(self, changes: SecurityChanges) -> None:
        for security in changes.added_securities:
            security.set_fee_model(data_tools.CustomFeeModel())

    def fundamental_selection_function(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # update the data every day
        for stock in fundamental:
            symbol: Symbol = stock.symbol
            
            if symbol in self._data:
                self._data[symbol].update_daily_price(stock.adjusted_price)
                # store monthly price
                if self._selection_flag:
                    self._data[symbol].update_monthly_price(stock.adjusted_price)

        if not self._selection_flag:
            return Universe.UNCHANGED

        selected: List[Fundamental] = [
            x for x in fundamental
            if x.has_fundamental_data
            and x.market_cap != 0
            and not isnan(x.valuation_ratios.payout_ratio)
            and x.valuation_ratios.payout_ratio != 0
            and x.security_reference.exchange_id in self._exchange_codes
            and x.symbol.value not in self._excluded_tickers
        ]
        
        if len(selected) > self._fundamental_count:
            selected = [
                x for x in sorted(selected, key=self._fundamental_sorting_key, reverse=True)[:self._fundamental_count]
            ]

        payout_yield: Dict[Symbol, float] = {}

        # price warmup
        for stock in selected:
            symbol: Symbol = stock.symbol

            if symbol not in self._data:
                self._data[symbol] = data_tools.SymbolData(self._vol_period, self._mom_period)
                history: DataFrame = self.history(TradeBar, symbol, self._vol_period, Resolution.DAILY)
                if history.empty:
                    self.log(f"Not enough data for {symbol} yet.")
                    continue
                data: Series = history.loc[symbol]
                monthly_data: Series = data.groupby(pd.Grouper(freq='MS')).first()
                for time, row in data.iterrows():
                    self._data[symbol].update_daily_price(row.close)
                for time, row in monthly_data.iterrows():
                    self._data[symbol].update_monthly_price(row.close)

        sorted_vol: List[Fundamental] = sorted([
            x for x in selected if self._data[x.symbol].is_ready()
        ], key=lambda x: self._data[x.symbol].get_vol())

        if len(sorted_vol) == 0:
            self.log(f'Not enough data for further calculation - volatility sorted stock count: {len(sorted_vol)}')
            return Universe.UNCHANGED

        # Divide stocks sorted by volatilities into half.
        quantile: int = len(sorted_vol) // 2
        low_vol: List[Fundamental] = sorted_vol[:quantile]
        high_vol: List[Fundamental] = sorted_vol[-quantile:]

        if all(len(vol_port) >= self._stock_count for vol_port in [low_vol, high_vol]):
            for i, vol_port in enumerate([high_vol, low_vol]):
                # Rank based on momentum and net payout yield.
                sorted_momentum: List[Symbol] = sorted(list(map(
                    lambda x: x.symbol, vol_port 
                )), key=lambda x: self._data[x].get_momentum(), reverse=bool(i))

                momentum_ranks: Dict[Symbol, int] = {
                    symbol: rank for rank, symbol in enumerate(sorted_momentum)
                }

                sorted_payout_yield: List[Symbol] = sorted(vol_port, key=lambda x: x.valuation_ratios.payout_ratio, reverse=bool(i))
                value_ranks: Dict[Symbol, int] = {
                    stock.symbol: rank for rank, stock in enumerate(sorted_payout_yield)
                }

                aggregated_ranks: List[Symbol] = sorted({
                    x.symbol: momentum_ranks[x.symbol] + value_ranks[x.symbol] for x in vol_port
                }.items(), key=lambda x: x[1])[:self._stock_count]

                for symbol, rank in aggregated_ranks:
                    weight: float = self._short_weight if i == 0 else self._long_weight
                    self._weight[symbol] = (-1 * ((-1) ** i)) * weight / len(aggregated_ranks)
        else:
            self.log(f'Not enough data for further calculation - low volatility portfolio count: {len(low_vol)}; high volatility portfolio count: {len(high_vol)}')

        if len(selected) == 0:
            return Universe.UNCHANGED

        return list(self._weight.keys())

    def on_data(self, slice: Slice) -> None:
        # order execution
        if not self._selection_flag:
            return
        self._selection_flag = False

        portfolio: List[PortfolioTarget] = [
            PortfolioTarget(symbol, w) for symbol, w in self._weight.items() if slice.contains_key(symbol) and slice[symbol]
        ]

        if not slice.contains_key(self._risk_free_asset) or not slice[self._risk_free_asset]:
            return
        portfolio.append(PortfolioTarget(self._risk_free_asset, self._risk_free_weight))

        self.set_holdings(portfolio, True)

    def selection(self) -> None:
        self._selection_flag = True
        self._weight.clear()