Overall Statistics
Total Orders
3652
Average Win
0.52%
Average Loss
-0.61%
Compounding Annual Return
24.535%
Drawdown
55.500%
Expectancy
0.224
Start Equity
10000000
End Equity
89868971.59
Net Profit
798.690%
Sharpe Ratio
0.602
Sortino Ratio
0.698
Probabilistic Sharpe Ratio
3.859%
Loss Rate
34%
Win Rate
66%
Profit-Loss Ratio
0.85
Alpha
0.079
Beta
1.474
Annual Standard Deviation
0.333
Annual Variance
0.111
Information Ratio
0.455
Tracking Error
0.261
Treynor Ratio
0.136
Total Fees
$519710.96
Estimated Strategy Capacity
$50000000.00
Lowest Capacity Asset
PLAB R735QTJ8XC9X
Portfolio Turnover
2.80%
Drawdown Recovery
1098
# region imports
from AlgorithmImports import *
# endregion


class CapitalGainStockMomentumAlgorithm(QCAlgorithm):

    def initialize(self) -> None:
        self.set_start_date(2016, 7, 29)
        self.set_end_date(2026, 7, 29)
        self.set_cash(10_000_000)
        self.settings.seed_initial_prices = True
        self.settings.minimum_order_margin_portfolio_percentage = 0
        # Add a universe of US Equities.
        date_rule = self.date_rules.month_end('SPY')
        self.universe_settings.resolution = Resolution.DAILY
        self.universe_settings.schedule.on(date_rule)
        self._universe = self.add_universe(self._select_assets)
        # Add a member to track the momentum of each Equity.
        self._momentum_by_symbol = {}
        # Add a Scheduled Event to rebalance the portfolio each month.
        self.schedule.on(date_rule, self.time_rules.at(8, 0), self._rebalance)
        # Add a warm-up period to warm up the momentum indicators.
        self.set_warm_up(timedelta(400))

    def _select_assets(self, fundamentals: List[Fundamental]) -> List[Symbol]:
        # Update the momentum indicators of all the stocks.
        fundamentals = [
            f for f in fundamentals
            if self._momentum_by_symbol.setdefault(f.symbol, RateOfChange(11)).update(f.end_time, f.adjusted_price)    
        ]
        # Wait until the warm-up period is over.
        if self.is_warming_up:
            return []
        # Select the subset of liquid stocks that pass our filters.
        screened_symbols = []
        nyse_symbols = set()
        for f in sorted(fundamentals, key=lambda f: f.dollar_volume)[-1000:]:
            # Apply filters: 
            if (f.has_fundamental_data and
                # - US-incorporated
                f.company_reference.country_id == "USA" and  
                # - Public operating company
                f.company_reference.company_status == "U" and 
                # - Common stock
                f.security_reference.security_type == "ST00000001" and
                f.security_reference.is_primary_share and
                # - Listed on NYSE / NASDAQ / AMEX
                f.security_reference.exchange_id in {"NYS", "NAS", "ASE"} and
                f.price >= 5 and
                # - Not a utilities firm (electric, gas, and combination services)
                not(4900 <= f.asset_classification.sic <= 4949) and  
                # - Not a financial firm (banks, brokers, insurance, real estate/REITs)
                not(6000 <= f.asset_classification.sic <= 6999)): 
                screened_symbols.append(f.symbol)
                # Mark the stocks that are trading on the New York Stock Exchange. 
                # We'll need this set later.
                if f.security_reference.exchange_id == "NYS":
                    nyse_symbols.add(f.symbol)
        # Classify the stocks into two groups: those that pay dividends and those that don't.
        dividend_paying_symbols = {
            dividend.symbol
            for dividends_by_symbol in self.history[Dividend](screened_symbols, timedelta(365))
            for dividend in dividends_by_symbol.values()
        }
        capital_gain_symbols = set(screened_symbols) - dividend_paying_symbols
        # Get the momentum of t-12 through t-1 for each stock.
        momentum_by_symbol = {symbol: self._momentum_by_symbol[symbol].previous.value for symbol in screened_symbols}
        # Calculate the 95th percentile of momentum scores using only NYSE-listed stocks.
        nyse_momentum_values = [momentum_by_symbol[symbol] for symbol in nyse_symbols]
        threshold = np.percentile(nyse_momentum_values, 95)
        # Select the stocks that don't pay dividends and have greater momentum than the 95th percentile.
        return [symbol for symbol in capital_gain_symbols if momentum_by_symbol[symbol] > threshold]

    # Each month, form an equal-weighted portfolio of the stocks in the universe.
    def _rebalance(self) -> None:
        if self.is_warming_up:
            return
        symbols = self._universe.selected
        weight = 1/len(symbols)
        self.set_holdings([PortfolioTarget(symbol, weight) for symbol in symbols], True)
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"