Overall Statistics
Total Orders
18671
Average Win
0.23%
Average Loss
-0.20%
Compounding Annual Return
13.268%
Drawdown
44.400%
Expectancy
0.206
Start Equity
1000000
End Equity
34565475.80
Net Profit
3356.548%
Sharpe Ratio
0.57
Sortino Ratio
0.603
Probabilistic Sharpe Ratio
3.671%
Loss Rate
44%
Win Rate
56%
Profit-Loss Ratio
1.16
Alpha
0.041
Beta
0.648
Annual Standard Deviation
0.132
Annual Variance
0.018
Information Ratio
0.222
Tracking Error
0.101
Treynor Ratio
0.117
Total Fees
$1589016.15
Estimated Strategy Capacity
$78000.00
Lowest Capacity Asset
MCW XPMG4J9N0TID
Portfolio Turnover
7.02%
Drawdown Recovery
785
# region imports
from AlgorithmImports import *
from collections import deque
# endregion


class MaxEffectAlgorithm(QCAlgorithm):

    def initialize(self) -> None:
        self.set_cash(1_000_000)
        self.set_end_date(2026, 6, 1)
        self.settings.seed_initial_prices = True
        # Add a universe of US Equities.
        self._selection_data_by_symbol = {}
        self.universe_settings.resolution = Resolution.DAILY
        self._universe = self.add_universe(self._select_assets)
        # Add VIX to drive portfolio leverage decisions.
        self._vix = self.add_data(CBOE, 'VIX', Resolution.DAILY)
        self._low_vix = 15
        self._high_vix = 30
        self._previous_vix = None
        self._current_targets = []
        # Add a Scheduled Event to rebalance the portfolio.
        self.schedule.on(self.date_rules.month_start('SPY', 1), self.time_rules.at(8, 0), self._rebalance)
        # Define some parameters.
        self._top_returns = self.get_parameter('top_returns', 5)
        self._max_portfolio_size = self.get_parameter('max_portfolio_size', 25)

    def _select_assets(self, fundamentals: List[Fundamental]) -> List[Symbol]:
        # Update SelectionData objects.
        today_symbols = set()
        selected_symbols = set()
        for f in fundamentals:
            # Apply universe filters.
            if (f.company_reference.is_reit or
                f.security_reference.is_depositary_receipt or
                not f.has_fundamental_data or
                f.price < 5 or
                f.market_cap < 1_000_000_000):
                continue
            today_symbols.add(f.symbol)
            data = self._selection_data_by_symbol.setdefault(f.symbol, SelectionData(top_returns=self._top_returns))
            if data.update(f):
                selected_symbols.add(f.symbol)
        # Remove SelectionData objects for assets that have fallen out of the universe.
        for symbol in list(self._selection_data_by_symbol.keys()):
            if symbol not in today_symbols:
                del self._selection_data_by_symbol[symbol]
        return list(selected_symbols)

    def _rebalance(self) -> None:
        securities = [self.securities[symbol] for symbol in self._universe.selected]
        if not securities:
            return
        # Get the factor of each asset.
        factor_by_security = {}
        for security in securities:
            data = self._selection_data_by_symbol[security.symbol]
            if security.price and data.dollar_volume_sma.current.value >= 5_000_000:
                factor_by_security[security] = data.factor
        # Select the subset of stocks in the bottom deciles of factor values.
        sorted_by_factor = sorted(factor_by_security, key=lambda security: factor_by_security[security])
        selection_size = min(self._max_portfolio_size, len(sorted_by_factor) // 10)
        securities = sorted_by_factor[:selection_size]
        self._current_securities = securities
        # Place trades to rebalance the portfolio.
        weight = 1 / len(securities)
        self._trade({security: weight for security in securities})

    def on_data(self, data: Slice) -> None:
        # Get the current VIX value.
        if self._vix not in data:
            return
        vix_value = data[self._vix].value
        if self._previous_vix is None:
            self._previous_vix = vix_value
            return
        # Check if VIX crossed a threshold.
        vix_crossed = (
            (self._previous_vix < self._low_vix and vix_value >= self._low_vix) or
            (self._previous_vix >= self._low_vix and vix_value < self._low_vix) or
            (self._previous_vix < self._high_vix and vix_value >= self._high_vix) or
            (self._previous_vix >= self._high_vix and vix_value < self._high_vix)
        )
        self._previous_vix = vix_value
        if not (vix_crossed and self._current_securities):
            return
        # Adjust the portfolio leverage, while keeping relative weights between assets.
        value_by_security = {security: security.holdings.holdings_value for security in self._current_securities if security.price}
        total_holdings_value = sum(value_by_security.values())
        self._trade({security: holdings_value / total_holdings_value for security, holdings_value in value_by_security.items()})
    
    def _trade(self, weight_by_security):
        # Determine the portfolio leverage based on the VIX.
        vix = self._vix.price
        if not vix or vix >= self._high_vix:
            leverage = 1.0
        elif vix <= self._low_vix:
            leverage = 1.5
        else:
            leverage = 1.5 - (vix - self._low_vix) / self._low_vix * 0.5
        # Scale portfolio weights based on target portfolio exposure.
        targets = [PortfolioTarget(s, leverage * w ) for s, w in weight_by_security.items()]
        self.set_holdings(targets, liquidate_existing_holdings=True)


class SelectionData:

    def __init__(self, period=21, top_returns=5) -> None:
        self.dollar_volume_sma = SimpleMovingAverage(period)
        self._daily_return = RateOfChange(1)
        self._daily_return.window.size = period
        self._top_returns = top_returns

    def update(self, f: Fundamental) -> bool:
        self.dollar_volume_sma.update(f.end_time, f.dollar_volume)
        self._daily_return.update(f.end_time, f.adjusted_price)
        return self.is_ready

    @property
    def is_ready(self) -> bool:
        return self._daily_return.samples > self._daily_return.window.size

    @property
    def factor(self) -> float:
        # Get the mean of the top n trailing daily returns.
        return float(np.mean(sorted([x.value for x in self._daily_return.window])[-self._top_returns:]))