Overall Statistics
#region imports
from AlgorithmImports import *
#endregion


class NewsSentimentAlphaModel(AlphaModel):

    _securities = []
    
    # Assign sentiment values to words
    _word_scores = {'good': 1, 'great': 1, 'best': 1, 'growth': 1,
                    'bad': -1, 'terrible': -1, 'worst': -1, 'loss': -1}

    def update(self, algorithm: QCAlgorithm, data: Slice) -> List[Insight]:
        insights = []

        for security in self._securities:
            if not security.exchange.hours.is_open(algorithm.time + timedelta(minutes=1), extended_market_hours=False):
                continue
            if not data.contains_key(security.dataset_symbol):
                continue
            article = data[security.dataset_symbol]

            # Assign a sentiment score to the article
            title_words = article.description.lower()
            score = 0
            for word, word_score in self._word_scores.items():
                if word in title_words:
                    score += word_score
                    
            # Only trade when there is positive news
            if score > 0:
                direction = InsightDirection.UP
            elif score < 0:
                direction = InsightDirection.FLAT
            else: 
                continue

            # Create insights
            expiry = security.exchange.hours.get_next_market_close(algorithm.time, extended_market_hours=False) - timedelta(minutes=1, seconds=1)
            insights.append(Insight.price(security.symbol, expiry, direction, None, None, None, 1/len(self._securities)))

        return insights

    def on_securities_changed(self, algorithm: QCAlgorithm, changes: SecurityChanges) -> None:
        for security in changes.added_securities:
            # Subscribe to the Tiingo News Feed for this security
            security.dataset_symbol = algorithm.add_data(TiingoNews, security.symbol).symbol
            self._securities.append(security)

        for security in changes.removed_securities:
            if security.symbol in self._securities:
                # Unsubscribe from the Tiingo News Feed for this security
                algorithm.remove_security(self.dataset_symbol)
                self._securities.remove(security)
# region imports
from AlgorithmImports import *

from universe import FaangUniverseSelectionModel
from alpha import NewsSentimentAlphaModel
from portfolio import PartitionedPortfolioConstructionModel
# endregion


class BreakingNewsEventsAlgorithm(QCAlgorithm):

    _undesired_symbols_from_previous_deployment = []
    _checked_symbols_from_previous_deployment = False

    def initialize(self):
        self.set_end_date(datetime.now())
        self.set_start_date(self.end_date - timedelta(5*365))
        self.set_cash(1_000_000)
        
        self.settings.minimum_order_margin_portfolio_percentage = 0

        self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.MARGIN)

        self.universe_settings.data_normalization_mode = DataNormalizationMode.RAW
        universe = FaangUniverseSelectionModel()
        self.add_universe_selection(universe)

        self.add_alpha(NewsSentimentAlphaModel())

        # We use 5 partitions because the FAANG universe has 5 members.
        # If we change the universe to have, say, 100 securities, then 100 paritions means
        #  that each trade gets a 1% (1/100) allocation instead of a 20% (1/5) allocation.
        self.set_portfolio_construction(PartitionedPortfolioConstructionModel(self, universe.count))

        self.add_risk_management(NullRiskManagementModel())

        self.set_execution(ImmediateExecutionModel()) 

    def on_data(self, data):
        # Exit positions that aren't backed by existing insights.
        # If you don't want this behavior, delete this method definition.
        if not self.is_warming_up and not self._checked_symbols_from_previous_deployment:
            for security_holding in self.portfolio.values():
                if not security_holding.invested:
                    continue
                symbol = security_holding.symbol
                if not self.insights.has_active_insights(symbol, self.utc_time):
                    self._undesired_symbols_from_previous_deployment.append(symbol)
            self._checked_symbols_from_previous_deployment = True
        
        for symbol in self._undesired_symbols_from_previous_deployment:
            if self.is_market_open(symbol):
                self.liquidate(symbol, tag="Holding from previous deployment that's no longer desired")
                self._undesired_symbols_from_previous_deployment.remove(symbol)
#region imports
from AlgorithmImports import *
#endregion


class PartitionedPortfolioConstructionModel(PortfolioConstructionModel):
    
    def __init__(self, algorithm, num_partitions):
        self._algorithm = algorithm
        self._NUM_PARTITIONS = num_partitions

    # REQUIRED: Will determine the target percent for each insight
    def determine_target_percent(self, activeInsights: List[Insight]) -> Dict[Insight, float]:        
        target_pct_by_insight = {}
        
        # Sort insights by time they were emmited
        insights_sorted_by_time = sorted(activeInsights, key=lambda x: x.generated_time_utc)

        # Find target securities and group insights by Symbol
        target_symbols = []
        insight_by_symbol = {}
        for insight in insights_sorted_by_time:
            insight_by_symbol[insight.symbol] = insight
            if len(target_symbols) < self._NUM_PARTITIONS:
                target_symbols.append(insight.symbol)

        occupied_portfolio_value = 0
        occupied_partitions = 0
        # Get last insight emmited for each target Symbol
        for symbol, insight in insight_by_symbol.items():
            # Only invest in Symbols in `target_symbols`
            if symbol not in target_symbols:
                target_pct_by_insight[insight] = 0
            else:
                security_holding = self._algorithm.portfolio[symbol]
                # If we're invested in the security in the proper direction, do nothing
                if (security_holding.is_short and insight.direction == InsightDirection.DOWN or 
                    security_holding.is_long and insight.direction == InsightDirection.UP):
                    occupied_portfolio_value += security_holding.absolute_holdings_value
                    occupied_partitions += 1
                    continue

                # If currently invested and there but the insight direction has changed, 
                #  change portfolio weight of security and reset set partition size
                if (security_holding.is_short and insight.direction == InsightDirection.UP or 
                    security_holding.is_long and insight.direction == InsightDirection.DOWN):
                    target_pct_by_insight[insight] = int(insight.direction)

                # If not currently invested, set portfolio weight of security with partition size
                if not security_holding.invested:
                    target_pct_by_insight[insight] = int(insight.direction)

        # Scale down target percentages to respect partitions (account for liquidations from insight expiry + universe removals)
        total_portfolio_value = self._algorithm.portfolio.total_portfolio_value
        free_portfolio_pct = (total_portfolio_value - occupied_portfolio_value) / total_portfolio_value
        vacant_partitions = self._NUM_PARTITIONS - occupied_partitions
        scaling_factor = free_portfolio_pct / vacant_partitions if vacant_partitions != 0 else 0
        for insight, target_pct in target_pct_by_insight.items():
            target_pct_by_insight[insight] = target_pct * scaling_factor

        return target_pct_by_insight

    # Determines if the portfolio should be rebalanced base on the provided rebalancing func
    def is_rebalance_due(self, insights: List[Insight], algorithmUtc: datetime) -> bool:
        # Rebalance when any of the following cases are true:
        #  Case 1: A security we're invested in was removed from the universe
        #  Case 2: The latest insight for a Symbol we're invested in has expired
        #  Case 3: The insight direction for a security we're invested in has changed
        #  Case 4: There is an insight for a security we're not currently invested in AND there is an available parition in the portfolio

        last_active_insights = self.get_target_insights() # Warning: This assumes that all insights have the same duration
        insight_symbols = [insight.symbol for insight in last_active_insights]
        num_investments = 0
        for symbol, security_holding in self._algorithm.portfolio.items():
            if not security_holding.invested:
                continue
            num_investments += 1
            #  Case 1: A security we're invested in was removed from the universe
            #  Case 2: The latest insight for a Symbol we're invested in has expired
            if symbol not in insight_symbols:
                return True
        
        for insight in last_active_insights:
            security_holding = self._algorithm.portfolio[insight.symbol]
            #  Case 3: The insight direction for a security we're invested in has changed
            if (security_holding.is_short and insight.direction == InsightDirection.UP or 
                security_holding.is_long and insight.direction == InsightDirection.DOWN):
                return True

            #  Case 4: There is an insight for a security we're not currently invested in AND there is an available parition in the portfolio
            if not security_holding.invested and num_investments < self._NUM_PARTITIONS:
                return True

        return False
#region imports
from AlgorithmImports import *
#endregion
# 07/13/2023: -Replaced the SymbolData class by with custom Security properties
#             -Fixed warm-up logic to liquidate undesired portfolio holdings on re-deployment
#             -Set the MinimumOrderMarginPortfolioPercentage to 0
#             https://www.quantconnect.com/terminal/processCache?request=embedded_backtest_99f55f8195cad4c3801449cf2b5699e6.html 
#
# 04/15/2024: -Updated to PEP8 style
#             https://www.quantconnect.com/terminal/processCache?request=embedded_backtest_b459552222b3a180c519d650ca1597bc.html
#region imports
from AlgorithmImports import *
#endregion


class FaangUniverseSelectionModel(ManualUniverseSelectionModel):
    def __init__(self):
        tickers = ["META", "AAPL", "AMZN", "NFLX", "GOOGL"]
        self.count = len(tickers)
        symbols = [Symbol.create(ticker, SecurityType.EQUITY, Market.USA) for ticker in tickers]
        super().__init__(symbols)