Overall Statistics
Total Orders
569
Average Win
2.29%
Average Loss
-1.28%
Compounding Annual Return
57.091%
Drawdown
40.900%
Expectancy
0.704
Start Equity
1000000
End Equity
9733221.76
Net Profit
873.322%
Sharpe Ratio
1.318
Sortino Ratio
1.468
Probabilistic Sharpe Ratio
66.170%
Loss Rate
39%
Win Rate
61%
Profit-Loss Ratio
1.80
Alpha
0.303
Beta
1.15
Annual Standard Deviation
0.312
Annual Variance
0.097
Information Ratio
1.314
Tracking Error
0.242
Treynor Ratio
0.358
Total Fees
$21480.27
Estimated Strategy Capacity
$360000000.00
Lowest Capacity Asset
LLY R735QTJ8XC9X
Portfolio Turnover
6.14%
#region imports
from AlgorithmImports import *
import numpy as np
from collections import deque
import statsmodels.api as sm
import statistics as stat
import pickle
#endregion

class Q2PlaygroundAlgorithm(QCAlgorithm):
    def Initialize(self):
        self.SetStartDate(2019, 3, 1)   # Set Start Date
        self.SetEndDate(2024, 6, 1)     # Set End Date
        self.SetCash(1000000)             # Set Strategy Cash
        self.SetSecurityInitializer(BrokerageModelSecurityInitializer(
            self.BrokerageModel, FuncSecuritySeeder(self.GetLastKnownPrices)
        ))

        #################################################################
        self.universe_settings.resolution = Resolution.DAILY

        self._momp = {}          # Dict of Momentum indicator keyed by Symbol
        self._lookback = 252     # Momentum indicator lookback period
        self._num_coarse = 200   # Number of symbols selected at Coarse Selection
        self._num_fine = 70      # Number of symbols selected at Fine Selection
        self._num_long = 5       # Number of symbols with open positions

        self._month = -1
        self._rebalance = False
        self.current_holdings = set()  # To track current holdings

        self.add_universe(self._coarse_selection_function, self._fine_selection_function)

    def _coarse_selection_function(self, coarse):
        '''Drop securities which have no fundamental data or have too low prices.
        Select those with highest by dollar volume'''
        if self._month == self.time.month:
            return Universe.UNCHANGED

        self._rebalance = True
        self._month = self.time.month

        selected = sorted([x for x in coarse if x.has_fundamental_data and x.price > 5],
            key=lambda x: x.dollar_volume, reverse=True)

        return [x.symbol for x in selected[:self._num_coarse]]

    def _fine_selection_function(self, fine):
        '''Select security with highest market cap'''
        selected = sorted(fine, key=lambda f: f.market_cap, reverse=True)
        return [x.symbol for x in selected[:self._num_fine]]

    def on_data(self, data):
        # Update the indicator
        for symbol, mom in self._momp.items():
            mom.update(self.time, self.securities[symbol].close)

        if not self._rebalance:
            return

        # Selects the securities with highest momentum
        sorted_mom = sorted([k for k,v in self._momp.items() if v.is_ready],
            key=lambda x: self._momp[x].current.value, reverse=True)
        selected = sorted_mom[:self._num_long]
        new_holdings = set(selected)

        # Only rebalance if the new selection is different from current holdings
        if new_holdings != self.current_holdings:
            if len(selected) > 0:
                optimal_weights = self.optimize_portfolio(selected)
                self.adjust_portfolio(optimal_weights, selected)
                self.current_holdings = new_holdings

        self._rebalance = False

    def on_securities_changed(self, changes):
        # Clean up data for removed securities and Liquidate
        for security in changes.removed_securities:
            symbol = security.symbol
            if self._momp.pop(symbol, None) is not None:
                self.liquidate(symbol, 'Removed from universe')

        for security in changes.added_securities:
            if security.symbol not in self._momp:
                self._momp[security.symbol] = MomentumPercent(self._lookback)

        # Warm up the indicator with history price if it is not ready
        added_symbols = [k for k,v in self._momp.items() if not v.is_ready]

        history = self.history(added_symbols, 1 + self._lookback, Resolution.DAILY)
        history = history.close.unstack(level=0)

        for symbol in added_symbols:
            ticker = symbol.id.to_string()
            if ticker in history:
                for time, value in history[ticker].dropna().items():
                    item = IndicatorDataPoint(symbol, time.date(), value)
                    self._momp[symbol].update(item)

    def optimize_portfolio(self, selected_symbols):
        short_lookback = 63
        returns = self.history(selected_symbols, short_lookback, Resolution.DAILY)['close'].unstack(level=0).pct_change().dropna()
        n_assets = len(selected_symbols)
        n_portfolios = 1000

        results = np.zeros((3, n_portfolios))
        weights_record = []

        for i in range(n_portfolios):
            weights = np.random.random(n_assets)
            weights /= np.sum(weights)

            portfolio_return = np.sum(returns.mean() * weights) * short_lookback
            portfolio_stddev = np.sqrt(np.dot(weights.T, np.dot(returns.cov() * short_lookback, weights)))

            downside_stddev = np.sqrt(np.mean(np.minimum(0, returns).apply(lambda x: x**2, axis=0).dot(weights)))
            sortino_ratio = portfolio_return / downside_stddev

            results[0,i] = portfolio_return
            results[1,i] = portfolio_stddev
            results[2,i] = sortino_ratio

            weights_record.append(weights)

        best_sortino_idx = np.argmax(results[2])
        return weights_record[best_sortino_idx]

    def adjust_portfolio(self, weights, selected):
        # First liquidate all positions
        for symbol in self.Portfolio.Keys:
            self.Liquidate(symbol)

        # Set holdings based on the optimal weights
        for i, symbol in enumerate(selected):
            self.SetHoldings(symbol, weights[i])