Overall Statistics
Total Orders
8533
Average Win
0.17%
Average Loss
-0.21%
Compounding Annual Return
22.920%
Drawdown
47.800%
Expectancy
0.237
Start Equity
10000000
End Equity
88501167.53
Net Profit
785.012%
Sharpe Ratio
0.642
Sortino Ratio
0.707
Probabilistic Sharpe Ratio
5.096%
Loss Rate
31%
Win Rate
69%
Profit-Loss Ratio
0.81
Alpha
0.051
Beta
1.326
Annual Standard Deviation
0.253
Annual Variance
0.064
Information Ratio
0.471
Tracking Error
0.167
Treynor Ratio
0.123
Total Fees
$394757.06
Estimated Strategy Capacity
$74000000.00
Lowest Capacity Asset
SIDU XUBT0M6O6L45
Portfolio Turnover
2.48%
Drawdown Recovery
1338
# region imports
from AlgorithmImports import *
# endregion


class MomentumAndDividendsAlgorithm(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2016, 1, 1)
        self.set_end_date(2026, 7, 23)
        self.set_cash(10_000_000)
        self.settings.seed_initial_prices = True
        self.settings.minimum_order_margin_portfolio_percentage = 0

        self._spy = self.add_equity("SPY", Resolution.DAILY).symbol
        month_end = self.date_rules.month_end(self._spy)
        self.universe_settings.resolution = Resolution.DAILY
        self.universe_settings.schedule.on(month_end)
        self._universe = self.add_universe(self._select_assets)
        self.schedule.on(
            month_end,
            self.time_rules.at(8, 0),
            self._rebalance,
        )

        self._targets = {}

    def _select_assets(self, fundamentals: List[Fundamental]) -> List[Symbol]:
        screened_symbols = []
        nyse_symbols = set()
        listed_exchanges = {"NYS", "NAS", "ASE"}

        fundamentals = sorted(fundamentals, key=lambda f: f.dollar_volume)[-1000:]

        for fundamental in fundamentals:
            if not fundamental.has_fundamental_data:
                continue
            if fundamental.company_reference.country_id != "USA":
                continue
            if fundamental.company_reference.company_status != "U":
                continue
            if fundamental.security_reference.security_type != "ST00000001":
                continue
            if not fundamental.security_reference.is_primary_share:
                continue
            if fundamental.security_reference.exchange_id not in listed_exchanges:
                continue
            if fundamental.price < 5:
                continue

            sic_code = fundamental.asset_classification.sic
            if 4900 <= sic_code <= 4949 or 6000 <= sic_code <= 6999:
                continue

            screened_symbols.append(fundamental.symbol)
            if fundamental.security_reference.exchange_id == "NYS":
                nyse_symbols.add(fundamental.symbol)

        formation_time = self.time
        lookback_start_month = formation_time.month + 1
        lookback_start_year = formation_time.year - 1
        if lookback_start_month == 13:
            lookback_start_month = 1
            lookback_start_year = formation_time.year
        lookback_start = datetime(
            lookback_start_year, lookback_start_month, 1
        )
        dividends = self.history[Dividend](
            screened_symbols, lookback_start, formation_time
        )
        dividend_paying_symbols = {
            dividend.symbol
            for dividends_by_symbol in dividends
            for dividend in dividends_by_symbol.values()
        }
        capital_gain_symbols = set(screened_symbols) - dividend_paying_symbols

        momentum_dates = [
            self._month_end(formation_time, months_back)
            for months_back in range(12, 1, -1)
        ]
        momentum_history = self.history[TradeBar](
            screened_symbols,
            momentum_dates[0],
            momentum_dates[-1] + timedelta(days=1),
            Resolution.DAILY,
        )
        prices_by_symbol = {}
        momentum_date_set = {date.date() for date in momentum_dates}
        for bars_by_symbol in momentum_history:
            for bar in bars_by_symbol.values():
                if bar.end_time.date() in momentum_date_set:
                    prices_by_symbol.setdefault(bar.symbol, {})[
                        bar.end_time.date()
                    ] = bar.close

        momentum_values = {}
        for symbol in screened_symbols:
            prices = prices_by_symbol.get(symbol, {})
            if all(date.date() in prices for date in momentum_dates):
                momentum_values[symbol] = (
                    prices[momentum_dates[-1].date()]
                    / prices[momentum_dates[0].date()]
                    - 1
                )

        nyse_momentum_values = [
            momentum_values[symbol]
            for symbol in nyse_symbols
            if symbol in momentum_values
        ]
        if not nyse_momentum_values:
            self._targets = {}
            return []
        breakpoints = np.percentile(nyse_momentum_values, [20, 40, 60, 80])
        momentum_quintiles = {}
        for symbol, momentum in momentum_values.items():
            if momentum <= breakpoints[0]:
                momentum_quintiles[symbol] = 1
            elif momentum <= breakpoints[1]:
                momentum_quintiles[symbol] = 2
            elif momentum <= breakpoints[2]:
                momentum_quintiles[symbol] = 3
            elif momentum <= breakpoints[3]:
                momentum_quintiles[symbol] = 4
            else:
                momentum_quintiles[symbol] = 5

        long_candidates = {
            symbol for symbol in capital_gain_symbols
            if momentum_quintiles.get(symbol) == 5
        }
        short_candidates = {
            symbol for symbol in capital_gain_symbols
            if momentum_quintiles.get(symbol) == 1
        }
        if not long_candidates or not short_candidates:
            self._targets = {}
            return []

        targets = {}
        long_weight = 1 / len(long_candidates)
        short_weight = -0.5 / len(short_candidates)
        for symbol in long_candidates:
            targets[symbol] = long_weight
        #for symbol in short_candidates:
        #    targets[symbol] = short_weight

        self._targets = targets
        return list(long_candidates | short_candidates)

    def _rebalance(self) -> None:
        targets = [
            PortfolioTarget(symbol, weight)
            for symbol, weight in self._targets.items()
        ]
        self.set_holdings(targets, liquidate_existing_holdings=True)

    def _month_end(self, formation_time: datetime, months_back: int) -> datetime:
        month = formation_time.month - months_back
        year = formation_time.year
        while month <= 0:
            month += 12
            year -= 1
        if month == 12:
            next_month = datetime(year + 1, 1, 1)
        else:
            next_month = datetime(year, month + 1, 1)
        return self.securities[self._spy].exchange.hours.get_previous_trading_day(
            next_month
        )
SPEC = "# Momentum and Dividends — Capital-Gain-Stock Momentum Quintile Long-Short\n\n**Deutsche Bank \"Academic Insights\" (AI October 2025), item #94 — \"Cash equities — Momentum #3: Momentum and Dividends\" (p.291).**\n**Underlying paper:** Cannon & Lynch, *\"Return Extrapolation and Dividends\"* (January 2025; SSRN 3816782). Data CRSP/Compustat, July 1972 – December 2021. No author code published.\n\nImplement the **full method** below — the paper's momentum quintile long-short restricted to **non-dividend-paying (\"capital-gain\") stocks** (Table 8, the variant Deutsche Bank highlights). Do not build a reduced version, and do not substitute a different momentum definition or weighting scheme.\n\n---\n\n## Structured brief (for the QC Assistants)\n\n**Concept.** Classic price momentum is far stronger among stocks that pay no dividends. Each month, classify every eligible US common stock as dividend-paying or capital-gain (non-dividend-paying), rank the capital-gain stocks by their trailing momentum, and hold an equal-weighted long-short book: long the top momentum quintile, short the bottom quintile, rebalanced monthly.\n\n**Why the pattern exists.** Investors extrapolate past returns into future expectations, and this extrapolation operates on price changes rather than total returns. For dividend-paying stocks, attention is divided between the dividend stream and the price path, which measurably dampens extrapolative belief formation (social-media sentiment and analyst price targets respond up to five times more strongly to past returns for non-payers, and a stock's extrapolation exposure roughly halves in the year after it initiates a dividend). Stronger extrapolation means stronger delayed overreaction dynamics — so the momentum effect concentrates in capital-gain stocks: the paper finds roughly double the monthly momentum spread among non-payers versus payers, a difference no standard factor model explains, driven primarily by the more recent portion of the formation window, exactly as decaying extrapolative beliefs predict.\n\n**Investment universe.** Ordinary US-incorporated common shares, primary listing, on NYSE, NASDAQ, or AMEX. Exclude: stocks priced below $5 at the end of the prior month; financial firms (SIC 6000–6999); utilities (SIC 4900–4949). Membership is point-in-time (listings and delistings as they occurred).\n\n**Signal (two classifications, each month-end).**\n1. **Dividend status:** a stock is *dividend-paying* if its cumulative total return over the past 12 completed months exceeds its cumulative price-only change over the same window — equivalently, if it distributed any dividend during those 12 months. Otherwise it is a *capital-gain* stock. Only capital-gain stocks are traded.\n2. **Momentum:** the cumulative total return over the 11-month window that ends one month before the formation date (the standard skip-month convention; exact definition below).\n\n**Portfolio construction & rebalance.** Monthly, at month-end. Rank by momentum into quintiles using NYSE breakpoints; among capital-gain stocks, go LONG the top quintile and SHORT the bottom quintile, all positions equal-weighted within each leg, legs of equal gross size, total gross exposure 1x (never 2x). Hold one month; re-form at the next month-end.\n\n**Reference.** Cannon & Lynch (2025), SSRN 3816782 — §2 (data and dividend classification), §5.1 + Table 8 (portfolio construction, the momentum result), footnote 32 (universe filters), footnote 15 (classification-window robustness), Appendix B (variable definitions).\n\n---\n\n## Implementation specifics from the source paper\n\nNotation: let month t be the holding month, so the formation date is the last trading day of month t-1. All windows are calendar months, measured at monthly frequency from month-end to month-end.\n\n1. **Universe screens (applied at each formation date).**\n   - Ordinary common shares of US-incorporated operating companies, primary share class only (the paper's CRSP share-code 10/11 universe).\n   - Listed on NYSE, NASDAQ, or AMEX.\n   - Price >= $5 at the end of month t-1.\n   - Exclude SIC 6000-6999 (financials) and SIC 4900-4949 (utilities).\n\n2. **Dividend classification (the causal variant).** For each stock, over the 12 completed months t-12 through t-1:\n   ```\n   dividend_paying  =  (cumulative TOTAL return over t-12..t-1)  >  (cumulative PRICE-ONLY change over t-12..t-1)\n   ```\n   The two quantities differ exactly when at least one dividend was distributed in the window, so an equivalent implementation is: dividend_paying = (any cash dividend with ex-date inside months t-12..t-1). A stock with equal cumulative return and price change is a capital-gain stock. The paper's headline tables include the contemporaneous month in this window; its footnote 15 confirms the results are robust to excluding it, and the tradable implementation MUST exclude it (the contemporaneous month is not observable at formation). Price changes are split-adjusted; total returns are split- and dividend-adjusted.\n\n3. **Momentum variable.** Cumulative total return over months t-12 through t-2 inclusive — an 11-month window ending at the end of month t-2, skipping the most recent completed month (t-1):\n   ```\n   MOM = P_adj(end of t-2) / P_adj(end of t-12, i.e. the close 12 month-ends before t-1) - 1\n   ```\n   computed on the split- and dividend-adjusted price series. A stock must have the full 11-month history to receive a momentum value; stocks without it are excluded that month.\n\n4. **Quintile formation — NYSE breakpoints.** Compute the quintile breakpoints (20th/40th/60th/80th percentiles of MOM) using ONLY NYSE-listed stocks that pass the screens, then assign ALL eligible stocks (all three exchanges) to quintiles using those breakpoints. Quintile 5 = highest momentum, quintile 1 = lowest. The dividend split and the momentum sort are independent: breakpoints come from the full eligible NYSE sample, not from capital-gain stocks only.\n\n5. **The traded book.** Among capital-gain stocks only:\n   - LONG every capital-gain stock in momentum quintile 5, equal-weighted.\n   - SHORT every capital-gain stock in momentum quintile 1, equal-weighted.\n   - Legs of equal gross size; total gross exposure 1x (long leg 0.5, short leg 0.5). Dollar-neutral.\n   - Rebalance the entire book at each month-end formation; positions held one month. Stocks that delist mid-month leave the book at their delisting (no special handling beyond the platform's delisting processing).\n\n---\n\n# === CLIENT-DIRECTED OVERRIDE (2026-07-24) - supersedes: “Data CRSP/Compustat, July 1972 – December 2021.” - directed change: “set start backtest to 2016” ===\n"