Overall Statistics
Total Orders
7164
Average Win
0.30%
Average Loss
-0.25%
Compounding Annual Return
160.615%
Drawdown
10.100%
Expectancy
0.180
Start Equity
1000000
End Equity
1637648.11
Net Profit
63.765%
Sharpe Ratio
3.911
Sortino Ratio
7.332
Probabilistic Sharpe Ratio
96.374%
Loss Rate
46%
Win Rate
54%
Profit-Loss Ratio
1.20
Alpha
0.731
Beta
1.144
Annual Standard Deviation
0.242
Annual Variance
0.059
Information Ratio
3.459
Tracking Error
0.219
Treynor Ratio
0.829
Total Fees
$5573.49
Estimated Strategy Capacity
$6500000.00
Lowest Capacity Asset
BIOA R735QTJ8XC9X
Portfolio Turnover
260.20%
#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.rebalanceTime = None
        self.rebalanced = False

        self.Schedule.On(self.DateRules.every_day(self.etf), self.TimeRules.AfterMarketOpen(self.etf, 20), self.Rebalance)

    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 Rebalance(self):
        # Check if today is the first day of the month and if we have already rebalanced
        if self.Time.day == 1 and self.rebalanced:
            self.Debug("Already rebalanced this month")
            return

        # Rebalancing logic
        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)

        # 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)
       
        # Set rebalanced flag to True
        self.rebalanced = True
    
    def CheckPositions(self):
        # If no positions are on, call Rebalance
        if not self.Portfolio.Invested:
            self.Debug("No positions on, calling Rebalance")
            self.Rebalance()

        # Reset rebalanced flag if it's not 1st of the Month
        if self.Time.day != 1:
            self.rebalanced = False