Overall Statistics
Total Orders
578
Average Win
2.10%
Average Loss
-1.18%
Compounding Annual Return
45.105%
Drawdown
33.400%
Expectancy
0.619
Start Equity
1000000
End Equity
7029586.93
Net Profit
602.959%
Sharpe Ratio
1.333
Sortino Ratio
1.495
Probabilistic Sharpe Ratio
74.870%
Loss Rate
42%
Win Rate
58%
Profit-Loss Ratio
1.78
Alpha
0.246
Beta
0.626
Annual Standard Deviation
0.228
Annual Variance
0.052
Information Ratio
1.002
Tracking Error
0.211
Treynor Ratio
0.485
Total Fees
$19478.36
Estimated Strategy Capacity
$120000000.00
Lowest Capacity Asset
ETN R735QTJ8XC9X
Portfolio Turnover
6.00%
#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 MonthlyRebalancingWithEarlyStop(QCAlgorithm):
    def initialize(self):
        self.set_start_date(2019, 3, 1)   # Set Start Date
        self.set_end_date(2024, 8, 1)     # Set End Date
        self.initial_cash = 1000000
        self.set_cash(self.initial_cash)  # Set Strategy Cash
        self.set_security_initializer(BrokerageModelSecurityInitializer(
            self.BrokerageModel, FuncSecuritySeeder(self.GetLastKnownPrices)
        ))

        self.p_lookback = 252
        self.p_num_coarse = 200
        self.p_num_fine = 70
        self.p_num_long = 5
        self.p_adjustment_step = 1.0
        self.p_n_portfolios = 1000
        self.p_short_lookback = 63
        self.p_rand_seed = 13
        self.p_adjustment_frequency = 'monthly'  # Can be 'monthly', 'weekly', 'bi-weekly'
        
        # TODO: Change resolution to minute
        self.universe_settings.resolution = Resolution.DAILY
        # self.set_benchmark(self.add_equity('SPY').symbol)  # This will affect the algo

        self._momp = {}          # Dict of Momentum indicator keyed by Symbol
        self._lookback = self.p_lookback     # Momentum indicator lookback period
        self._num_coarse = self.p_num_coarse # Number of symbols selected at Coarse Selection
        self._num_fine = self.p_num_fine     # Number of symbols selected at Fine Selection
        self._num_long = self.p_num_long     # Number of symbols with open positions

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

        self.target_weights = {}  # To store target weights
        self.adjustment_step = self.p_adjustment_step  # Adjustment step for gradual transition
        self._short_lookback = self.p_short_lookback

        self.first_trade_date = None
        self.next_adjustment_date = None

        # Metrics for no trades and profit tracking
        self.no_trade_days = 0
        self.highest_profit = 0
        self.lowest_profit = float('inf')
        self.monthly_starting_equity = 0
        self.last_logged_month = None  # 用于记录上次输出日志的月份
        self.global_stop_loss_triggered = False  # 标志是否已经触发了全局止损
        self.halved_lookback = False  # Track whether the lookback has been halved
        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.next_adjustment_date and self.time < self.next_adjustment_date:
            return Universe.UNCHANGED

        self._rebalance = True

        if not self.first_trade_date:
            self.first_trade_date = self.time
            self.next_adjustment_date = self.get_next_adjustment_date(self.time)
            self._rebalance = True

        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):
        for symbol, mom in self._momp.items():
            mom.update(self.time, self.securities[symbol].close)

        if self.monthly_starting_equity == 0:
            self.monthly_starting_equity = self.Portfolio.TotalPortfolioValue

        current_portfolio_value = self.Portfolio.TotalPortfolioValue
        if self.monthly_starting_equity != 0:
            current_profit_pct_to_start = ((current_portfolio_value - self.monthly_starting_equity) / self.monthly_starting_equity) * 100
        else:
            current_profit_pct_to_start = 0

        self.highest_profit = max(self.highest_profit, current_profit_pct_to_start)

        if self.highest_profit != 0:
            drop_pct = ((self.highest_profit - current_profit_pct_to_start) / self.highest_profit) * 100
        else:
            drop_pct = 0

        if current_profit_pct_to_start <= -12 and not self.global_stop_loss_triggered:
            current_date = self.Time.strftime('%Y-%m-%d %H:%M:%S')
            self.debug(f"{current_date}: Liquidating all holdings due to a portfolio loss of {current_profit_pct_to_start:.2f}% (stop-loss from last adjustment).")
            self.Liquidate()
            self._rebalance = False  # Allow immediate rebalancing
            self.global_stop_loss_triggered = True
            self.highest_profit = 0
            self.monthly_starting_equity = 0
            self.next_adjustment_date = self.get_next_adjustment_date(self.time)

            # if not self.halved_lookback:
            #     self._lookback //= 2  # Halve the lookback period
            #     self.halved_lookback = True  # Mark that the lookback has been halved
            # else:
            #     self.debug(f"{current_date}: Stopping trading temporarily due to repeated stop-loss trigger.")
            self.debug(f"{current_date}: Stopping trading temporarily due to stop-loss trigger.")
            return

        if self.highest_profit > 10 and drop_pct >= 10:
            current_date = self.Time.strftime('%Y-%m-%d %H:%M:%S')
            self.debug(f"{current_date}: Liquidating all holdings due to a {drop_pct:.2f}% drop in profit (take-profit).")
            self.debug(f"{current_date}: Highest Net Profit: {self.highest_profit:.2f}% (from last adjustment)")
            self.debug(f"{current_date}: Current Net Profit: {current_profit_pct_to_start:.2f}% (from last adjustment)")
            total_profit_pct = ((current_portfolio_value - self.initial_cash) / self.initial_cash) * 100
            self.debug(f"{current_date}: Total Net Profit: {total_profit_pct:.2f}% (from inception)")
            self.Liquidate()
            self._rebalance = True  # Allow immediate rebalancing
            self.global_stop_loss_triggered = True
            self.highest_profit = 0
            self.monthly_starting_equity = 0
            self.next_adjustment_date = self.get_next_adjustment_date(self.time)

            if not self.halved_lookback:
                # self._lookback //= 2  # Halve the lookback period
                self._short_lookback //= 7
                self.halved_lookback = True  # Mark that the lookback has been halved
            return

        if self.Time.day == 1 and (self.Time.month != self.last_logged_month):
            current_date = self.Time.strftime('%Y-%m-%d %H:%M:%S')
            portfolio_value = self.Portfolio.TotalPortfolioValue
            net_profit = portfolio_value - self.initial_cash
            holdings_value = sum([sec.HoldingsValue for sec in self.Portfolio.Values if sec.Invested])
            unrealized_profit = self.Portfolio.TotalUnrealizedProfit
            return_pct = (net_profit / self.initial_cash) * 100
            self.debug(f"{current_date}: Equity: ${portfolio_value:.2f} | Holdings: ${holdings_value:.2f} | Net Profit: ${net_profit:.2f} | Unrealized: ${unrealized_profit:.2f} | Return: {return_pct:.2f}%")
            self.last_logged_month = self.Time.month

            if self.halved_lookback:
                self._lookback = self.p_lookback  # Restore the original lookback period
                self._short_lookback = self.p_short_lookback  # Restore the original short lookback period
                self.halved_lookback = False  # Reset the halved lookback flag

        if not self._rebalance:
            return

        if self._rebalance:
            self.global_stop_loss_triggered = False
            self._rebalance = False

        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)

        if new_holdings != self.current_holdings or self.first_trade_date == self.time:
            if len(selected) > 0:
                optimal_weights = self.optimize_portfolio(selected)
                self.target_weights = dict(zip(selected, optimal_weights))
                self.current_holdings = new_holdings
                self.adjust_portfolio()

        self._rebalance = False
        self.next_adjustment_date = self.get_next_adjustment_date(self.time)



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

        for security in changes.AddedSecurities:
            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.IsReady]
        history = self.History(added_symbols, 1 + self._lookback, Resolution.DAILY)
        history = history.close.unstack(level=0)

        for symbol in added_symbols:
            ticker = symbol.ID.ToString()
            if ticker in history:
                for time, value in history[ticker].dropna().items():
                    item = IndicatorDataPoint(symbol, time, value)
                    self._momp[symbol].Update(item)

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

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

        np.random.seed(self.p_rand_seed)

        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):
        current_symbols = set(self.portfolio.keys())
        target_symbols = set(self.target_weights.keys())

        # Liquidate removed symbols
        removed_symbols = current_symbols - target_symbols
        for symbol in removed_symbols:
            self.Liquidate(symbol)

        # Adjust holdings for selected symbols
        for symbol, target_weight in self.target_weights.items():
            current_weight = self.portfolio[symbol].Quantity / self.portfolio.TotalPortfolioValue if symbol in self.portfolio else 0
            adjusted_weight = current_weight * (1 - self.adjustment_step) + target_weight * self.adjustment_step
            self.SetHoldings(symbol, adjusted_weight)
        holdings = {}
        sum_of_all_holdings = 0
        for symbol in self.portfolio.keys():
            holding_percentage = self.portfolio[symbol].HoldingsValue / self.portfolio.TotalPortfolioValue * 100
            if holding_percentage > 1e-4:
                sum_of_all_holdings += holding_percentage
                holdings[symbol.ID.to_string().split(" ")[0]] = round(holding_percentage, 2)
        current_date = self.Time.strftime('%Y-%m-%d %H:%M:%S')
        self.debug(f"{current_date}: Final holdings [{sum_of_all_holdings:.2f}%]: {holdings}")

    def get_next_adjustment_date(self, current_date, initial=False):
        if self.p_adjustment_frequency == 'weekly':
            return current_date + timedelta(days=7)
        elif self.p_adjustment_frequency == 'bi-weekly':
            return current_date + timedelta(days=14)
        elif self.p_adjustment_frequency == 'monthly':
            if initial:
                next_month = current_date.replace(day=1) + timedelta(days=32)
                return next_month.replace(day=1)
            next_month = current_date.replace(day=1) + timedelta(days=32)
            return next_month.replace(day=1)
        else:
            raise ValueError(f"Unsupported adjustment frequency: {self.p_adjustment_frequency}")