Overall Statistics
Total Orders
133
Average Win
1.48%
Average Loss
-1.11%
Compounding Annual Return
91.891%
Drawdown
17.800%
Expectancy
0.454
Start Equity
1000000
End Equity
1328222.18
Net Profit
32.822%
Sharpe Ratio
2.036
Sortino Ratio
2.721
Probabilistic Sharpe Ratio
73.977%
Loss Rate
38%
Win Rate
62%
Profit-Loss Ratio
1.33
Alpha
0.362
Beta
1.365
Annual Standard Deviation
0.285
Annual Variance
0.081
Information Ratio
1.634
Tracking Error
0.257
Treynor Ratio
0.425
Total Fees
$1508.35
Estimated Strategy Capacity
$25000000.00
Lowest Capacity Asset
CYTK SY8OYP5ZLDUT
Portfolio Turnover
7.04%
#region imports
from AlgorithmImports import *
#endregion


# Your New Python File
class MomentumAlpha(AlphaModel):
    
    def __init__(
        self, 
        long_period=252, 
        short_period=21,
        long_percent=0.2, 
        short_percent=0.2
    ):
        self.long_period = long_period
        self.short_period = short_period
        self.long_percent = long_percent
        self.short_percent = short_percent
        self.mom_by_symbol = {}
        self.last_month = -1
    

    def update(self, algorithm, data):
        # emit monthly insight for now
        if self.last_month == algorithm.Time.month:
            return []
        self.last_month = algorithm.Time.month

        mom_scores = []
        for symbol in self.mom_by_symbol:
            if self.mom_by_symbol[symbol].is_ready():
                #algorithm.log(f"{algorithm.Time}: {symbol} is ready")
                long_momentum = self.mom_by_symbol[symbol].get_momentum_percent(self.short_period, self.long_period-1)
                short_momentum = self.mom_by_symbol[symbol].get_momentum_percent(0, self.short_period-1)
                mom_scores.append([symbol, long_momentum, short_momentum])
        # size_in_bytes = sys.getsizeof(self.mom_by_symbol[symbol])
        # algorithm.log(f"mom rolling window size: {size_in_bytes}")
                
        if not mom_scores:
            return []
        
        # algorithm.log(f"{algorithm.Time}: Number of assets available is {len(mom_scores)}")
        mom_scores_df = pd.DataFrame(mom_scores, columns=['symbol', 'long_momentum', 'short_momentum'])
        mom_scores_df['long_rank'] = mom_scores_df['long_momentum'].rank(ascending=False) #high long momentum receives higher(smaller) rank
        mom_scores_df['long_bucket'] = pd.qcut(mom_scores_df['long_rank'], 10, labels=False) + 1
        highest_rank_bucket = mom_scores_df[mom_scores_df['long_bucket'] == 1]
        sorted_by_short_momentum = highest_rank_bucket.sort_values(by='short_momentum', ascending=True)

        

        insights = []
        num_long = int(self.long_percent * len(sorted_by_short_momentum))
        for index, row in sorted_by_short_momentum.iloc[:num_long].iterrows():
            symbol = row['symbol']
            insights.append(Insight.Price(symbol, timedelta(days=10), InsightDirection.Up))
        
        del mom_scores_df, highest_rank_bucket, sorted_by_short_momentum

        return insights
    


    def on_securities_changed(self, algorithm, changes):
        for security in changes.RemovedSecurities:
            # algorithm.log(f"{algorithm.time}: Removed {security.Symbol}")
            if security.Symbol in self.mom_by_symbol:
                del self.mom_by_symbol[security.Symbol]
                
            

        for security in changes.AddedSecurities:
            # algorithm.log(f"{algorithm.time}: Added {security.Symbol}")
            if security not in self.mom_by_symbol:
                history_by_symbol = algorithm.History(security.Symbol, self.long_period, Resolution.DAILY)
                self.mom_by_symbol[security.Symbol] = LongMomentumShortReversalIndicator(security.Symbol, self.long_period, algorithm, history_by_symbol)
                #register indicator for daily update
                algorithm.RegisterIndicator(security.Symbol, self.mom_by_symbol[security.Symbol], Resolution.DAILY)
                # size_in_bytes = sys.getsizeof(history_by_symbol)
                # algorithm.log(f"mom rolling window size: {size_in_bytes / (1024 * 1024)}")
                del history_by_symbol
                
class LongMomentumShortReversalIndicator(PythonIndicator):
    
    def __init__(self, symbol, period, algorithm, history):
        self.symbol = symbol
        self.period = period
        self.value = 0
        self.rollingWindow = RollingWindow[float](self.period)
        #warm up indicator with history
        if not history.empty:
            for _, tradebar in history.loc[symbol].iterrows():
                self.update(tradebar)
            # algorithm.log(f"{algorithm.Time}: {symbol}")
        
    
    def update(self, bar):
        self.rollingWindow.add(bar.close)
        return self.rollingWindow.is_ready
    
    def is_ready(self):
        return self.rollingWindow.is_ready
    
    # percentage momentum between time_0 and time_n calculated as:
    # (price[time_0] - price[time_n]) / price[time_n]
    def get_momentum_percent(self, time_0, time_n):
        return 100 * (self.rollingWindow[time_0] - self.rollingWindow[time_n]) / self.rollingWindow[time_n]
# region imports
from AlgorithmImports import *
from AlphaModels import MomentumAlpha
# endregion

class CompetitionTemplate(QCAlgorithm):

    def Initialize(self):
        self.set_start_date(2024, 1, 1)
        self.set_cash(1000000)

        self.last_month = -1
        self.num_coarse = 500
        self.universe_settings.Resolution = Resolution.DAILY
        self.add_universe(self.CoarseSelectionFunction, self.FineSelectionFunction)
        self.add_alpha(MomentumAlpha()) #customized alpha model
        self.set_portfolio_construction(EqualWeightingPortfolioConstructionModel(self.IsRebalanceDue))
        self.set_risk_management(NullRiskManagementModel())
        self.set_execution(ImmediateExecutionModel())


    #universe rebalance: monthly
    def IsRebalanceDue(self, time):
        if time.month == self.last_month:
            return None
        self.last_month = time.month
        return time

    #coarse selection: 1000 most liquid equity
    def CoarseSelectionFunction(self, coarse):
        if not self.IsRebalanceDue(self.Time):
            return Universe.UNCHANGED

        selected = sorted([x for x in coarse if x.HasFundamentalData],
                            key=lambda x: x.DollarVolume, reverse=True)
        return [x.Symbol for x in selected[:self.num_coarse]]
    
    #fine selection: sorted by market cap
    def FineSelectionFunction(self, fine):
        sorted_by_market_cap = sorted(fine, key=lambda f: f.market_cap, reverse=True)
        return [f.Symbol for f in sorted_by_market_cap]