Overall Statistics
Total Orders
967
Average Win
0.50%
Average Loss
-0.58%
Compounding Annual Return
4.025%
Drawdown
22.400%
Expectancy
0.229
Start Equity
100000000
End Equity
179821823.3
Net Profit
79.822%
Sharpe Ratio
0.173
Sortino Ratio
0.113
Probabilistic Sharpe Ratio
0.160%
Loss Rate
34%
Win Rate
66%
Profit-Loss Ratio
0.86
Alpha
-0.012
Beta
0.305
Annual Standard Deviation
0.087
Annual Variance
0.007
Information Ratio
-0.593
Tracking Error
0.124
Treynor Ratio
0.049
Total Fees
$627853.75
Estimated Strategy Capacity
$3100000000.00
Lowest Capacity Asset
ES YOGVNNAOI1OH
Portfolio Turnover
4.30%
# region imports
from AlgorithmImports import *
# endregion

class SwimmingLightBrownGalago(QCAlgorithm):

    def initialize(self):
        self.set_cash(100_000_000)

        self.settings.daily_precise_end_time = False
        self.settings.minimum_order_margin_portfolio_percentage = 0

        lookback_years = 12 # 12 years includes 3 non-leap years & 3 election cycles
        self._lookback = lookback_years * 252 
        self._max_period = 21 # Trading days
        self._percentile_threshold = 0.1
        self._margin_ratio_boundary = 0.1
        self._weight_scaler = 5
        self._can_short = False

        warm_up_period = timedelta(lookback_years * 365)
        self.set_start_date(datetime(1998, 1, 1) + warm_up_period)
        self.set_warm_up(warm_up_period)

        self._equity = self.add_equity("SPY", Resolution.DAILY, fill_forward=False)
        self._equity.history = pd.Series()
        self._equity.signals_by_period = {self._max_period: 0} # One-month holding period

        # Add E-mini Futures.
        self._future = self.add_future(
            Futures.Indices.SP_500_E_MINI,
            data_mapping_mode=DataMappingMode.OPEN_INTEREST,
            data_normalization_mode=DataNormalizationMode.BACKWARDS_RATIO,
            contract_depth_offset=0
        )
        self._future.set_filter(lambda universe: universe.front_month())
        self._target_weight = None

        self._vix = self.add_data(CBOE, "VIX", Resolution.DAILY, fill_forward=False)
        self._vix.threshold = 30

    def on_data(self, data: Slice):
        # Get the current day's trade bar.
        bar = data.bars.get(self._equity.symbol)
        if bar:    
            # Update the historical data.
            self._equity.history.loc[bar.time] = bar.open # `time` and `open` since we trade at next market open.
            self._equity.history = self._equity.history.iloc[-self._lookback:]
            
            # Wait until there is enough history.
            if len(self._equity.history) < self._lookback or self.is_warming_up or self._vix.price >= self._vix.threshold:
                return

            for period in self._equity.signals_by_period.keys():
                # Calculate expected return of entering at next market open.
                period_returns = self._equity.history.pct_change(period).shift(-period).dropna()
                expected_return_by_day = period_returns.groupby(period_returns.index.strftime('%m-%d')).mean()
                next_market_open = self._equity.exchange.hours.get_next_market_open(self.time, False)
                expected_return = expected_return_by_day[next_market_open.strftime('%m-%d')]  # The expected return of buying next market open and holdings n days.
                
                # Calculate signal for this trade.
                std = expected_return_by_day.std()
                mean = expected_return_by_day.mean()
                if expected_return >= mean + std:
                    signal = 1
                elif expected_return <= mean - std:
                    signal = -1
                else:
                    signal = 0

                if signal:
                    # Record the signal and create a Scheduled Event to open the trade.
                    self._equity.signals_by_period[period] += signal
                    self._schedule_rebalance()

                    # Create a Scheduled Event to remove the signal and close the trade.
                    exit_day = next_market_open
                    for _ in range(period):
                        exit_day = self._equity.exchange.hours.get_next_market_open(exit_day, False)
                    self.schedule.on(
                        self.date_rules.on(exit_day.replace(hour=0, minute=0)),
                        self.time_rules.midnight,
                        lambda period=period, signal=signal: self._remove_signal(period, signal)
                    )

        if self.is_warming_up:
            return
        
        if self._vix.symbol in data:
            self.plot('VIX', 'Value', self._vix.price)
        
        # If the margin ratio deviates far from the initial margin ratio when opening the trade, adjust it back to the 
        # original margin ratio.
        if self.portfolio.invested and self._target_weight:
            margin_ratio = self.portfolio.total_margin_used / (self.portfolio.total_margin_used + self.portfolio.margin_remaining)
            if not self._target_weight - self._margin_ratio_boundary < margin_ratio < self._target_weight + self._margin_ratio_boundary:
                self.set_holdings(self._future.mapped, self._target_weight)
        
    def _schedule_rebalance(self):
        # Create a Scheduled Event to rebalance at the next market open.
        self.schedule.on(
            self.date_rules.on(self._future.exchange.hours.get_next_market_open(self.time, False)),
            self.time_rules.after_market_open(self._future.symbol, 1),
            self._rebalance
        )

    def _rebalance(self):
        # Rebalance based on the signals.
        weighted_signals = [signal / period for period, signal in self._equity.signals_by_period.items()]
        weight = sum(weighted_signals) / len(weighted_signals) / self._weight_scaler
        if not self._can_short:
            weight = max(0, weight)
        self._target_weight = weight
        self.plot("Weight", self._future.symbol.value, weight)
        self.set_holdings([PortfolioTarget(self._future.mapped, weight)])

    def on_symbol_changed_events(self, symbol_changed_events):
        # Create a Scheduled Event to roll over to the new contract at next market open.
        changed_event = symbol_changed_events.get(self._future.symbol)
        self.schedule.on(
            self.date_rules.on(self._future.exchange.hours.get_next_market_open(self.time, False).replace(hour=0, minute=0)),
            self.time_rules.after_market_open(self._future.symbol, 1),
            lambda old_symbol=changed_event.old_symbol, new_symbol=changed_event.new_symbol: self._roll(old_symbol, new_symbol)
        )

    def _roll(self, old_symbol, new_symbol):
        # Roll to the new contract while maintaining the current margin ratio.
        margin_ratio = self.portfolio.total_margin_used / (self.portfolio.total_margin_used + self.portfolio.margin_remaining)
        self.liquidate(old_symbol, tag=f"Rollover - Symbol changed at {self.time}: {old_symbol} -> {new_symbol}")
        self.set_holdings(new_symbol, margin_ratio)

    def _remove_signal(self, period, signal):
        # Remove the signal and create a Scheduled Event to close the trade.
        self._equity.signals_by_period[period] -= signal
        self._schedule_rebalance()