Overall Statistics
Total Orders
30
Average Win
7.23%
Average Loss
-4.73%
Compounding Annual Return
19.595%
Drawdown
28.100%
Expectancy
0.751
Start Equity
1000000
End Equity
1859171.9
Net Profit
85.917%
Sharpe Ratio
0.541
Sortino Ratio
0.586
Probabilistic Sharpe Ratio
31.690%
Loss Rate
31%
Win Rate
69%
Profit-Loss Ratio
1.53
Alpha
0
Beta
0
Annual Standard Deviation
0.175
Annual Variance
0.031
Information Ratio
0.851
Tracking Error
0.175
Treynor Ratio
0
Total Fees
$219.30
Estimated Strategy Capacity
$0
Lowest Capacity Asset
ES Z3DIALNO785D
Portfolio Turnover
3.20%
Drawdown Recovery
262
from AlgorithmImports import *
import numpy as np
import math


class EndOfMonthSP500Reversal(QCAlgorithm):
    def initialize(self):
        self.set_start_date(2023, 1, 1)
        self.set_cash(1_000_000)

        # Add ES futures continuous contract (price-based, daily)
        self._es = self.add_future("ES", Resolution.DAILY, extended_market_hours=False)
        self._es.set_leverage(1.0)

        # Realistic costs
        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)

        # Monthly rebalance: first trading day of each month at 10:00 ET
        self.schedule.on(
            self.date_rules.month_start('SPY'),
            self.time_rules.at(10, 0),
            self._rebalance,
        )

        # Persistent data structures
        self._pairs = []               # list of (r^EoM_s, r_{s+1})
        self._eom_returns = {}         # month_key -> r^EoM
        self._month_end_prices = {}    # month_key -> log_price of last trading day
        self._processed_months = set() # months already added to regression

        # Seed initial ~102 months from history before backtest start
        self._seed_initial_window()

        self._current_weight = 0.0
        self._first_rebalance = True

    def _seed_initial_window(self):
        # ~2500 trading days gives roughly 102 months
        history = self.history(self._es.symbol, 2500, Resolution.DAILY)
        if history.empty:
            self.debug("No history available for initial window")
            return
        self._process_history(history)
        self.debug(f"Seeded initial window: {len(self._pairs)} pairs from {len(self._processed_months)} months")

    def _process_history(self, history):
        history = history.sort_index()
        closes = history['close'].values
        if len(closes) == 0:
            return

        log_prices = np.log(closes)

        # Extract dates from index: futures MultiIndex has (expiry, symbol, time)
        if isinstance(history.index, pd.MultiIndex):
            if 'time' in history.index.names:
                dates = history.index.get_level_values('time')
            else:
                dates = history.index.get_level_values(-1)
        else:
            dates = history.index

        # Group trading days by calendar month
        month_days = {}
        for i, d in enumerate(dates):
            d_date = pd.Timestamp(d).date()
            month_key = (d_date.year, d_date.month)
            if month_key not in month_days:
                month_days[month_key] = []
            month_days[month_key].append((i, d_date, log_prices[i]))

        # Process each month in chronological order
        for month_key in sorted(month_days.keys()):
            if month_key in self._processed_months:
                continue

            days = month_days[month_key]
            if len(days) < 5:
                self._processed_months.add(month_key)
                continue

            last_idx, last_date, last_log_price = days[-1]
            fourth_idx, fourth_date, fourth_log_price = days[-5]

            # r^EoM_t = log(p_last) - log(p_{4th-to-last})
            eom_return = last_log_price - fourth_log_price
            self._eom_returns[month_key] = eom_return
            self._month_end_prices[month_key] = last_log_price
            self._processed_months.add(month_key)

            # Monthly return: r_t = log(p_last_t) - log(p_last_{t-1})
            prev_month = self._get_prev_month(month_key)
            if prev_month in self._month_end_prices:
                monthly_return = last_log_price - self._month_end_prices[prev_month]

                # Add pair (r^EoM_{t-1}, r_t) if we have the EoM of the month before previous
                prev_eom_month = self._get_prev_month(prev_month)
                if prev_eom_month in self._eom_returns:
                    pair = (self._eom_returns[prev_eom_month], monthly_return)
                    self._pairs.append(pair)

    def _get_prev_month(self, month_key):
        year, month = month_key
        if month == 1:
            return (year - 1, 12)
        return (year, month - 1)

    def _rebalance(self):
        # Update with most recent history to catch any newly completed months
        history = self.history(self._es.symbol, 60, Resolution.DAILY)
        if not history.empty:
            self._process_history(history)

        # Latest completed month before the current month
        today = self.time.date()
        current_month = (today.year, today.month)

        completed_months = [m for m in sorted(self._eom_returns.keys()) if m < current_month]
        if len(completed_months) == 0:
            return

        latest_month = completed_months[-1]
        latest_eom = self._eom_returns[latest_month]

        # Need at least a reasonable number of pairs for regression
        if len(self._pairs) < 24:
            return

        # Expanding-window OLS
        X = np.array([[1.0, p[0]] for p in self._pairs])
        y = np.array([p[1] for p in self._pairs])

        try:
            beta = np.linalg.lstsq(X, y, rcond=None)[0]
            alpha_hat = beta[0]
            gamma_hat = beta[1]
        except Exception as e:
            self.debug(f"OLS error: {e}")
            return

        # Forecast next month's return
        r_hat = alpha_hat + gamma_hat * latest_eom

        # Variance of trailing 3 monthly returns (sample variance)
        monthly_returns = [p[1] for p in self._pairs]
        if len(monthly_returns) < 3:
            return

        trailing = np.array(monthly_returns[-3:])
        sigma2 = np.var(trailing, ddof=1)
        if sigma2 <= 0 or np.isnan(sigma2) or np.isinf(sigma2):
            sigma2 = 1e-10

        # Campbell-Thompson optimal allocation, risk aversion = 1
        w = r_hat / sigma2
        w = max(-1.0, min(1.5, w))
        self._current_weight = w

        if self._first_rebalance:
            self.debug(f"First rebalance: month={latest_month}, pairs={len(self._pairs)}, alpha={alpha_hat:.6f}, gamma={gamma_hat:.6f}, eom={latest_eom:.6f}, r_hat={r_hat:.6f}, sigma2={sigma2:.6f}, w={w:.4f}")
            self._first_rebalance = False

        # Trade
        self._trade_futures(w)

    def _trade_futures(self, target_weight):
        mapped = self._es.mapped
        if mapped is None or mapped == self._es.symbol:
            self.debug("No mapped contract available")
            return

        if mapped not in self.securities:
            self.debug("Mapped contract not in securities")
            return

        price = self.securities[mapped].price
        if price <= 0 or np.isnan(price):
            return

        symbol_props = self.securities[mapped].symbol_properties
        multiplier = symbol_props.contract_multiplier if symbol_props is not None else 50.0
        if multiplier is None or multiplier <= 0:
            multiplier = 50.0

        portfolio_value = self.portfolio.total_portfolio_value
        target_notional = portfolio_value * target_weight

        target_contracts = round(target_notional / (price * multiplier))
        current_contracts = self.portfolio[mapped].quantity

        delta = target_contracts - current_contracts
        if delta != 0:
            self.market_order(mapped, delta)
            self.debug(f"Rebalance: target_weight={target_weight:.4f}, contracts={target_contracts}, delta={delta}")

    def on_data(self, data):
        pass