Overall Statistics
Total Orders
430
Average Win
1.41%
Average Loss
-0.87%
Compounding Annual Return
14.604%
Drawdown
39.300%
Expectancy
0.497
Start Equity
100000
End Equity
259849.5
Net Profit
159.850%
Sharpe Ratio
0.464
Sortino Ratio
0.481
Probabilistic Sharpe Ratio
8.203%
Loss Rate
43%
Win Rate
57%
Profit-Loss Ratio
1.63
Alpha
0.033
Beta
0.781
Annual Standard Deviation
0.206
Annual Variance
0.043
Information Ratio
0.091
Tracking Error
0.167
Treynor Ratio
0.123
Total Fees
$695.46
Estimated Strategy Capacity
$640000000.00
Lowest Capacity Asset
CRWD X59VIZ423I3P
Portfolio Turnover
1.41%
Drawdown Recovery
451
from AlgorithmImports import *
import numpy as np

class MomentumFactorAlgorithm(QCAlgorithm):
    
    def initialize(self):
        self.set_start_date(2018, 1, 1)
        self.set_end_date(2024, 12, 31)
        self.set_cash(100000)
        
        self.universe_settings.resolution = Resolution.DAILY
        self.add_universe(self.coarse_selection)
        
        self.mom_window    = 252
        self.skip_window   = 21
        self.num_long      = 10
        self.rebalance_day = -1
        
        self.momentum_data = {}

        # Performance tracking
        self.daily_returns        = []
        self.prev_portfolio_value = self.portfolio.total_portfolio_value
        self.peak_value           = self.portfolio.total_portfolio_value
        self.max_drawdown         = 0

    # ------------------------------------------------------------------ #
    #  UNIVERSE                                                            #
    # ------------------------------------------------------------------ #

    def coarse_selection(self, coarse):
        filtered = [x for x in coarse
                    if x.has_fundamental_data
                    and x.price > 10
                    and x.dollar_volume > 5e7]
        sorted_by_volume = sorted(filtered,
                                  key=lambda x: x.dollar_volume,
                                  reverse=True)
        return [x.symbol for x in sorted_by_volume[:150]]

    def on_securities_changed(self, changes):
        for security in changes.added_securities:
            self.momentum_data[security.symbol] = True

        for security in changes.removed_securities:
            self.momentum_data.pop(security.symbol, None)
            if self.portfolio[security.symbol].invested:
                self.liquidate(security.symbol)

    # ------------------------------------------------------------------ #
    #  REBALANCE                                                           #
    # ------------------------------------------------------------------ #

    def on_data(self, data: Slice):
        if self.time.month == self.rebalance_day:
            return
        self.rebalance_day = self.time.month
        self._rebalance(data)

    def _rebalance(self, data):
        scores = {}
        vols   = {}

        for symbol in list(self.momentum_data.keys()):
            if symbol not in data.bars:
                continue

            history = self.history(symbol,
                                   self.mom_window + self.skip_window,
                                   Resolution.DAILY)
            if history.empty or len(history) < self.mom_window + self.skip_window:
                continue

            closes       = history['close']
            past_price   = closes.iloc[0]
            recent_price = closes.iloc[-(self.skip_window + 1)]

            if past_price <= 0:
                continue

            scores[symbol] = (recent_price - past_price) / past_price

            # 21-day realized vol for position sizing
            daily_rets     = closes.pct_change().dropna().tail(21)
            vol            = daily_rets.std()
            vols[symbol]   = vol if vol > 0 else 1e-6

        if len(scores) < self.num_long:
            return

        ranked       = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        long_symbols = [s for s, _ in ranked[:self.num_long]]

        # Exit stale positions
        for symbol in self.portfolio.keys():
            if self.portfolio[symbol].invested:
                if symbol not in long_symbols:
                    self.liquidate(symbol)

        # Vol-adjusted sizing — inverse vol weighting, 5% cash buffer
        inv_vols = {s: 1.0 / vols[s] for s in long_symbols if s in vols}
        total    = sum(inv_vols.values())

        for symbol in long_symbols:
            weight = inv_vols.get(symbol, 1.0 / self.num_long)
            self.set_holdings(symbol, 0.95 * weight / total)

    # ------------------------------------------------------------------ #
    #  PERFORMANCE TRACKING                                                #
    # ------------------------------------------------------------------ #

    def on_end_of_day(self, symbol):
        if symbol != next(iter(self.active_securities.keys()), None):
            return

        current_value = self.portfolio.total_portfolio_value

        if self.prev_portfolio_value > 0:
            daily_ret = (current_value - self.prev_portfolio_value) / self.prev_portfolio_value
            self.daily_returns.append(daily_ret)

        if current_value > self.peak_value:
            self.peak_value = current_value
        drawdown = (self.peak_value - current_value) / self.peak_value
        if drawdown > self.max_drawdown:
            self.max_drawdown = drawdown

        self.prev_portfolio_value = current_value

    def on_end_of_algorithm(self):
        returns = np.array(self.daily_returns)

        if len(returns) < 2:
            print("Not enough data for stats")
            return

        total_return = (self.portfolio.total_portfolio_value - 100000) / 100000
        ann_return   = (1 + total_return) ** (252 / len(returns)) - 1
        ann_vol      = returns.std() * np.sqrt(252)
        sharpe       = (ann_return - 0.05) / ann_vol if ann_vol > 0 else 0
        wins         = np.sum(returns > 0)
        win_rate     = wins / len(returns)
        calmar       = ann_return / self.max_drawdown if self.max_drawdown > 0 else 0

        print("=" * 50)
        print(f"TOTAL RETURN:    {total_return:.2%}")
        print(f"ANN. RETURN:     {ann_return:.2%}")
        print(f"ANN. VOLATILITY: {ann_vol:.2%}")
        print(f"SHARPE RATIO:    {sharpe:.3f}")
        print(f"MAX DRAWDOWN:    {self.max_drawdown:.2%}")
        print(f"CALMAR RATIO:    {calmar:.3f}")
        print(f"WIN RATE:        {win_rate:.2%}")
        print(f"TRADING DAYS:    {len(returns)}")
        print("=" * 50)