Overall Statistics
Total Orders
1225
Average Win
0.08%
Average Loss
-0.03%
Compounding Annual Return
24.797%
Drawdown
5.800%
Expectancy
1.722
Start Equity
100000
End Equity
155837.65
Net Profit
55.838%
Sharpe Ratio
1.713
Sortino Ratio
1.734
Probabilistic Sharpe Ratio
85.136%
Loss Rate
30%
Win Rate
70%
Profit-Loss Ratio
2.88
Alpha
0.118
Beta
0.275
Annual Standard Deviation
0.097
Annual Variance
0.009
Information Ratio
-0.067
Tracking Error
0.172
Treynor Ratio
0.605
Total Fees
$1220.34
Estimated Strategy Capacity
$0
Lowest Capacity Asset
MAA R735QTJ8XC9X
Portfolio Turnover
0.74%
# Filename: D_Volatility_Anomaly.py
from AlgorithmImports import *
import numpy as np

class DVolatilityAnomaly(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2020, 1, 1)  # Start after SPY inception and enough data for 252d lookback
        self.SetEndDate(2022, 1, 1)
        self.SetCash(100000)
        self.rebalance_flag = False
        self.lookback = 252
        self.num_stocks = 1000
        self.quintile = 0.2  # Lowest 20%
        self.spy = self.AddEquity("SPY", Resolution.Daily).Symbol
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverse(self.CoarseSelectionFunction)
        self.symbol_data = {}
        self.selected = []
        self.last_month = -1
        self.Schedule.On(self.DateRules.MonthStart(), self.TimeRules.AfterMarketOpen("SPY", 30), self.Rebalance)
        self.rolling_volatility = RollingWindow[float](12)  # For 12 months of realized volatility
        self.rolling_volatility_spy = RollingWindow[float](12)
        self.spy_returns = []
        self.portfolio_returns = []
        self.cum_returns = []
        self.cum_returns_spy = []
        self.last_portfolio_value = self.Portfolio.TotalPortfolioValue
        self.last_spy_price = None

    def CoarseSelectionFunction(self, coarse):
        # Filter for top 1000 by dollar volume, with price > $5
        filtered = [x for x in coarse if x.HasFundamentalData and x.Price > 5 and x.Volume > 0]
        sorted_by_dollar = sorted(filtered, key=lambda x: x.DollarVolume, reverse=True)
        return [x.Symbol for x in sorted_by_dollar[:self.num_stocks]]

    def OnSecuritiesChanged(self, changes):
        # Warm up history for new securities
        for added in changes.AddedSecurities:
            symbol = added.Symbol
            if symbol not in self.symbol_data:
                history = self.History(symbol, self.lookback, Resolution.Daily)
                closes = list(history["close"]) if not history.empty else []
                self.symbol_data[symbol] = closes
        # Remove data for dropped securities
        for removed in changes.RemovedSecurities:
            symbol = removed.Symbol
            if symbol in self.symbol_data:
                del self.symbol_data[symbol]

    def OnData(self, data):
        # Update rolling close prices for all tracked symbols
        for symbol in list(self.symbol_data.keys()):
            if symbol in data and data[symbol] is not None and hasattr(data[symbol], 'Close') and data[symbol].Close > 0:
                self.symbol_data[symbol].append(float(data[symbol].Close))
                if len(self.symbol_data[symbol]) > self.lookback:
                    self.symbol_data[symbol].pop(0)
            # Remove if no data for a while
            elif len(self.symbol_data[symbol]) == 0 or (symbol not in data):
                continue
        # For SPY, update rolling window for volatility and returns
        if self.spy in data and data[self.spy] is not None and hasattr(data[self.spy], 'Close'):
            close = float(data[self.spy].Close)
            if self.last_spy_price is not None:
                ret = (close - self.last_spy_price) / self.last_spy_price
                self.spy_returns.append(ret)
                if len(self.spy_returns) > self.lookback:
                    self.spy_returns.pop(0)
            self.last_spy_price = close
            # Compute rolling 12-month volatility for SPY
            if len(self.spy_returns) == self.lookback:
                vol = np.std(self.spy_returns) * np.sqrt(252)
                self.rolling_volatility_spy.Add(vol)
        # Compute portfolio return
        current_value = self.Portfolio.TotalPortfolioValue
        if self.last_portfolio_value > 0:
            ret = (current_value - self.last_portfolio_value) / self.last_portfolio_value
        else:
            ret = 0
        self.portfolio_returns.append(ret)
        if len(self.portfolio_returns) > self.lookback:
            self.portfolio_returns.pop(0)
        self.last_portfolio_value = current_value
        # Compute rolling 12-month volatility for portfolio
        if len(self.portfolio_returns) == self.lookback:
            vol = np.std(self.portfolio_returns) * np.sqrt(252)
            self.rolling_volatility.Add(vol)
        # Track cumulative returns for plotting
        if len(self.cum_returns) == 0:
            self.cum_returns.append(1)
        else:
            self.cum_returns.append(self.cum_returns[-1] * (1 + ret))
        if self.last_spy_price is not None and len(self.cum_returns_spy) == 0:
            self.cum_returns_spy.append(1)
        elif self.last_spy_price is not None and len(self.spy_returns) > 0:
            self.cum_returns_spy.append(self.cum_returns_spy[-1] * (1 + self.spy_returns[-1]))

    def Rebalance(self):
        # Only rebalance at the start of a new month
        if self.Time.month == self.last_month:
            return
        self.last_month = self.Time.month
        # Calculate volatility for each stock with enough data
        vol_dict = {}
        for symbol, closes in self.symbol_data.items():
            if len(closes) == self.lookback:
                returns = np.diff(closes) / closes[:-1]
                vol = np.std(returns) * np.sqrt(252)
                vol_dict[symbol] = vol
        if len(vol_dict) == 0:
            return
        # Select lowest quintile by volatility
        sorted_syms = sorted(vol_dict.items(), key=lambda x: x[1])
        num_select = max(1, int(len(sorted_syms) * self.quintile))
        selected_syms = [x[0] for x in sorted_syms[:num_select]]
        self.selected = selected_syms
        # Liquidate unselected positions
        for kvp in self.Portfolio:
            symbol = kvp.Key
            if symbol != self.spy and kvp.Value.Invested and symbol not in self.selected:
                self.Liquidate(symbol)
        # Equal weight allocation
        if len(self.selected) == 0:
            return
        weight = 1.0 / len(self.selected)
        for symbol in self.selected:
            if self.Securities.ContainsKey(symbol) and self.Securities[symbol].IsTradable:
                self.SetHoldings(symbol, weight)
        # Ensure no leverage
        for symbol in self.selected:
            if self.Securities.ContainsKey(symbol):
                self.Securities[symbol].SetLeverage(1)
        if self.Securities.ContainsKey(self.spy):
            self.Liquidate(self.spy)  # Do not hold SPY
        # Record stats for plotting
        if self.rolling_volatility.Count > 0 and self.rolling_volatility_spy.Count > 0:
            self.Plot("Volatility", "Portfolio 12m Vol", self.rolling_volatility[0])
            self.Plot("Volatility", "SPY 12m Vol", self.rolling_volatility_spy[0])
        if len(self.cum_returns) > 0 and len(self.cum_returns_spy) > 0:
            self.Plot("Cumulative Return", "Portfolio", self.cum_returns[-1])
            self.Plot("Cumulative Return", "SPY", self.cum_returns_spy[-1])