Overall Statistics
Total Orders
4327
Average Win
0.32%
Average Loss
-0.25%
Compounding Annual Return
21.525%
Drawdown
7.600%
Expectancy
0.120
Start Equity
1000000
End Equity
1130686.90
Net Profit
13.069%
Sharpe Ratio
0.816
Sortino Ratio
0.793
Probabilistic Sharpe Ratio
56.872%
Loss Rate
50%
Win Rate
50%
Profit-Loss Ratio
1.26
Alpha
0.072
Beta
0.129
Annual Standard Deviation
0.12
Annual Variance
0.014
Information Ratio
-0.726
Tracking Error
0.142
Treynor Ratio
0.762
Total Fees
$1827.05
Estimated Strategy Capacity
$23000000.00
Lowest Capacity Asset
BIOA R735QTJ8XC9X
Portfolio Turnover
67.36%
#region imports
from AlgorithmImports import *
#endregion


def CalculateTrendIndicators(self):
    
    top_pct= 0.1

    # Moving average calculation
    moving_averages = {}
    for symbol, prices in self.historical_data.items():
        if len(prices) >= self.lookback:
            moving_averages[symbol] = prices.mean()

    top_symbols = sorted(moving_averages, key=moving_averages.get, reverse=True)[:int(len(moving_averages) * top_pct)]
    
    # # Compounded Return Calculations
    # moving_averages = {}
    # compounded_returns = {}
    # for symbol, prices in self.historical_data.items():
    #     if len(prices) >= self.lookback:
    #         daily_returns = prices.pct_change().dropna()
    #         compounded_return = (1 + daily_returns).prod() - 1
    #         compounded_returns[symbol] = compounded_return

    # top_symbols = sorted(compounded_returns, key=compounded_returns.get, reverse=True)[:int(len(compounded_returns) * top_pct)]

    return top_symbols
#region imports
from AlgorithmImports import *
from pypfopt import BlackLittermanModel
import pandas as pd
import numpy as np
#endregion

def OptimizePortfolio(self, mu, S):

    # Black-Litterman views (neutral in this case)
    Q = pd.Series(index=mu.index, data=mu.values)
    P = np.identity(len(mu.index))

    # Optimize via Black-Litterman
    bl = BlackLittermanModel(S,Q=Q, P=P, pi=mu)
    bl_weights = bl.bl_weights()
    
    # Normalize weights
    total_weight = sum(bl_weights.values())
    normalized_weights = {symbol: weight / total_weight for symbol, weight in bl_weights.items()}
    
    return normalized_weights
#region imports
from pypfopt import risk_models, expected_returns
from AlgorithmImports import *
#endregion

def CalculateRiskParameters(self, top_symbols):

    # Get historical prices for selected symbols
    selected_history = self.History(top_symbols, self.lookback, Resolution.Daily)['close'].unstack(level=0)
        
    mu = expected_returns.mean_historical_return(selected_history)
    S = risk_models.sample_cov(selected_history)

    return mu, S
#region imports
from AlgorithmImports import *
#endregion

def Execute_Trades(self, position_list):

    # Place market orders
    for symbol, weight in position_list.items():
        self.SetHoldings(symbol, weight)


def Exit_Positions(self, position_list):
    
    # Liquidate positions not in the target weights
    for holding in self.Portfolio.Values:
        if holding.Symbol not in position_list and holding.Invested:
            self.Liquidate(holding.Symbol)
# region imports
from AlgorithmImports import *
from Alpha_Models import CalculateTrendIndicators
from Risk_Models import CalculateRiskParameters
from Portfolio_Construction import OptimizePortfolio
from Trade_Execution import Execute_Trades, Exit_Positions
# endregion

class NCSU_Strategy_2024_Q3(QCAlgorithm):

    def Initialize(self):
        self.SetStartDate(2023, 12, 1)  # Set Start Date
        self.SetEndDate(2024, 12, 31)  # Set End Date
        self.SetCash(1000000)  # Set Strategy Cash

        # ETF to get constituents from
        self.etf = "SPY"

        self.universe_settings.leverage = 2.0

        self.AddEquity(self.etf, Resolution.Daily)
        self.UniverseSettings.Resolution = Resolution.Daily
        self.AddUniverseSelection(ETFConstituentsUniverseSelectionModel(self.etf))

        self.historical_data = {}
        self.lookback = 252  # MAX lookback period for moving average calculation

        self.equity_high_water_mark = self.Portfolio.TotalPortfolioValue
        self.drawdown_threshold = 0.04  # 6% drawdown threshold
        self.rebalanced = False

    def OnSecuritiesChanged(self, changes):
        # Evaluate if performance is better by trading out of holdings dropped from ETF
        for security in changes.AddedSecurities:
            self.historical_data[security.Symbol] = self.History(security.Symbol, self.lookback, Resolution.Daily)['close']
        
        for security in changes.RemovedSecurities:
            if security.Symbol in self.historical_data:
                del self.historical_data[security.Symbol]
            
            if self.Portfolio[security.Symbol].Invested:
                self.Liquidate(security.Symbol)
                self.Debug(f"Liquidating {security.Symbol} as it is removed from the ETF")

    def OnData(self, data):
        if not self.rebalanced:
            # Check for drawdown condition
            current_equity = self.Portfolio.TotalPortfolioValue
            if (self.equity_high_water_mark - current_equity) / self.equity_high_water_mark >= self.drawdown_threshold:
                self.Debug(f"Drawdown exceeded {self.drawdown_threshold}. Rebalancing...")
                self.Rebalance()
                self.equity_high_water_mark = current_equity  # Update high water mark

    def Rebalance(self):
        self.Debug(f"Rebalancing on {self.Time}")

        # Alpha Model Output
        sorted_symbols = CalculateTrendIndicators(self)

        # Risk Model Output
        mu, S = CalculateRiskParameters(self, top_symbols=sorted_symbols)
        
        # Reduce mu by transaction costs
        transaction_cost = 0.001  # Assumed transaction cost per trade
        for symbol in sorted_symbols:
            if symbol in mu:
                mu[symbol] -= transaction_cost
            else:
                self.Debug(f"Symbol {symbol} not found in mu")

        # Portfolio Construction Output
        target_positions = OptimizePortfolio(self, mu=mu, S=S)

        Exit_Positions(self, position_list=target_positions)

        Execute_Trades(self, position_list=target_positions)

        self.rebalanced = True

    def OnOrderEvent(self, orderEvent):
        self.rebalanced = False  # Reset rebalanced flag after trades have been executed

    def OnEndOfDay(self):
        # Check for end of day to reset rebalanced flag if necessary
        if self.rebalanced == False:
            self.Rebalance()
            self.rebalanced = True