Overall Statistics
Total Orders
10
Average Win
1.39%
Average Loss
-2.22%
Compounding Annual Return
14.667%
Drawdown
33.400%
Expectancy
-0.389
Start Equity
100000
End Equity
144289.31
Net Profit
44.289%
Sharpe Ratio
0.32
Sortino Ratio
0.376
Probabilistic Sharpe Ratio
17.794%
Loss Rate
62%
Win Rate
38%
Profit-Loss Ratio
0.63
Alpha
-0.015
Beta
0.987
Annual Standard Deviation
0.219
Annual Variance
0.048
Information Ratio
-0.584
Tracking Error
0.027
Treynor Ratio
0.071
Total Fees
$34.65
Estimated Strategy Capacity
$15000000.00
Lowest Capacity Asset
AAPL R735QTJ8XC9X
Portfolio Turnover
1.33%
Drawdown Recovery
297
#region imports
from AlgorithmImports import *
#endregion

from QuantConnect.DataSource import *
from collections import deque


"""
TIINGO NEWS SENTIMENT MODEL FOR AAPL

This strategy trades AAPL using TiingoNews as a news-sentiment input.

The model reads Tiingo news descriptions for AAPL and assigns a simple dictionary-
based sentiment score. Positive words add to the score. Negative words subtract
from the score.

The strategy does not trade immediately on every article. Instead, it aggregates
news sentiment during the day and makes one portfolio decision near the market
close.

Sentiment logic:

- Positive rolling sentiment:
      long AAPL

- Negative rolling sentiment:
      short AAPL

- Neutral sentiment:
      cash

This is a simple pedagogical sentiment model. It does not use a machine-learning
language model, article relevance weighting, source credibility scoring, or
earnings/event classification. The goal is to show how news data can be converted
into a trading signal in QuantConnect.

Parameters:

    sentiment_window_days
        Number of daily sentiment observations used in the rolling sentiment
        score.

    long_weight
        Target AAPL allocation when sentiment is positive.

    short_weight
        Target AAPL allocation when sentiment is negative.

    sentiment_threshold
        Minimum absolute rolling sentiment required before taking a position.

    rebalance_threshold
        Minimum target-weight change required before sending a new order.

Benchmark:

The benchmark is buy-and-hold AAPL.
"""


class TiingoNewsDataAlgorithm(QCAlgorithm):

    def Initialize(self):

        # ------------------------------------------------------------
        # 1. BACKTEST SETTINGS
        # ------------------------------------------------------------
        self.SetStartDate(2023, 9, 1)
        self.SetEndDate(2026, 5, 5)

        self.initial_cash = 100000
        self.SetCash(self.initial_cash)

        # ------------------------------------------------------------
        # 2. TRADED ASSET
        # ------------------------------------------------------------
        self.aapl = self.AddEquity(
            "AAPL",
            Resolution.Minute
        ).Symbol

        self.SetBenchmark(self.aapl)

        # ------------------------------------------------------------
        # 3. TIINGO NEWS DATA
        # ------------------------------------------------------------
        self.tiingo_symbol = self.AddData(
            TiingoNews,
            self.aapl
        ).Symbol

        # ------------------------------------------------------------
        # 4. PARAMETERS
        # ------------------------------------------------------------
        self.sentiment_window_days = self.GetIntParameter(
            "sentiment_window_days",
            5
        )

        self.long_weight = self.GetFloatParameter(
            "long_weight",
            1.00
        )

        self.short_weight = self.GetFloatParameter(
            "short_weight",
            -0.50
        )

        self.sentiment_threshold = self.GetFloatParameter(
            "sentiment_threshold",
            1.00
        )

        self.rebalance_threshold = self.GetFloatParameter(
            "rebalance_threshold",
            0.05
        )

        # Safety checks.
        self.sentiment_window_days = max(1, self.sentiment_window_days)
        self.long_weight = max(-1.0, min(1.5, self.long_weight))
        self.short_weight = max(-1.5, min(1.0, self.short_weight))
        self.sentiment_threshold = max(0.0, self.sentiment_threshold)
        self.rebalance_threshold = max(0.0, self.rebalance_threshold)

        # ------------------------------------------------------------
        # 5. SENTIMENT DICTIONARY
        # ------------------------------------------------------------
        self.word_scores = {
            # Positive words
            "good": 1,
            "great": 1,
            "best": 1,
            "growth": 1,
            "profit": 1,
            "profits": 1,
            "beat": 1,
            "beats": 1,
            "upgrade": 1,
            "upgraded": 1,
            "strong": 1,
            "record": 1,
            "surge": 1,
            "bullish": 1,
            "positive": 1,

            # Negative words
            "bad": -1,
            "terrible": -1,
            "worst": -1,
            "loss": -1,
            "losses": -1,
            "miss": -1,
            "misses": -1,
            "downgrade": -1,
            "downgraded": -1,
            "weak": -1,
            "decline": -1,
            "falls": -1,
            "fall": -1,
            "bearish": -1,
            "negative": -1,
            "lawsuit": -1,
            "probe": -1,
            "investigation": -1
        }

        # ------------------------------------------------------------
        # 6. STATE
        # ------------------------------------------------------------
        self.daily_sentiment_score = 0
        self.daily_article_count = 0

        self.sentiment_history = deque(
            maxlen=self.sentiment_window_days
        )

        self.current_target_weight = 0.0
        self.initial_aapl_price = None

        # ------------------------------------------------------------
        # 7. HISTORICAL NEWS DIAGNOSTIC
        # ------------------------------------------------------------
        history = self.History(
            self.tiingo_symbol,
            14,
            Resolution.Daily
        )

        try:
            self.Debug(
                "TiingoNews history request returned "
                + str(len(history))
                + " rows."
            )
        except:
            self.Debug("TiingoNews history request completed.")

        # ------------------------------------------------------------
        # 8. DAILY REBALANCE
        # ------------------------------------------------------------
        self.Schedule.On(
            self.DateRules.EveryDay(self.aapl),
            self.TimeRules.BeforeMarketClose(self.aapl, 10),
            self.Rebalance
        )

    def OnData(self, data):

        # ------------------------------------------------------------
        # 1. READ NEWS EVENTS
        # ------------------------------------------------------------
        if data.ContainsKey(self.tiingo_symbol):

            news_item = data[self.tiingo_symbol]

            text = ""

            if hasattr(news_item, "Description") and news_item.Description is not None:
                text += " " + str(news_item.Description)

            if hasattr(news_item, "Title") and news_item.Title is not None:
                text += " " + str(news_item.Title)

            text = text.lower()

            article_score = self.ScoreText(text)

            self.daily_sentiment_score += article_score
            self.daily_article_count += 1

            if article_score != 0:

                self.Debug(
                    str(self.Time)
                    + " | News score="
                    + str(article_score)
                    + " | Daily score="
                    + str(self.daily_sentiment_score)
                )

    def Rebalance(self):

        # ------------------------------------------------------------
        # 1. UPDATE ROLLING SENTIMENT
        # ------------------------------------------------------------
        self.sentiment_history.append(
            self.daily_sentiment_score
        )

        rolling_sentiment = sum(self.sentiment_history)

        # Reset daily counters after storing the score.
        article_count = self.daily_article_count

        self.daily_sentiment_score = 0
        self.daily_article_count = 0

        # ------------------------------------------------------------
        # 2. CONVERT SENTIMENT TO TARGET WEIGHT
        # ------------------------------------------------------------
        if rolling_sentiment >= self.sentiment_threshold:

            target_weight = self.long_weight
            signal = 1

        elif rolling_sentiment <= -self.sentiment_threshold:

            target_weight = self.short_weight
            signal = -1

        else:

            target_weight = 0.0
            signal = 0

        # ------------------------------------------------------------
        # 3. EXECUTE ONLY IF TARGET CHANGES MEANINGFULLY
        # ------------------------------------------------------------
        current_weight = self.GetCurrentWeight(self.aapl)

        if abs(target_weight - current_weight) >= self.rebalance_threshold:

            self.SetHoldings(
                self.aapl,
                target_weight
            )

            self.current_target_weight = target_weight

            self.Debug(
                str(self.Time.date())
                + " | Rolling sentiment="
                + str(rolling_sentiment)
                + " | Articles today="
                + str(article_count)
                + " | Target AAPL weight="
                + str(round(target_weight, 2))
            )

        # ------------------------------------------------------------
        # 4. PLOTS
        # ------------------------------------------------------------
        self.PlotDiagnostics(
            rolling_sentiment,
            article_count,
            signal,
            target_weight
        )

    def ScoreText(self, text):

        score = 0

        for word, word_score in self.word_scores.items():

            if word in text:
                score += word_score

        return score

    def PlotDiagnostics(
        self,
        rolling_sentiment,
        article_count,
        signal,
        target_weight
    ):

        aapl_price = self.Securities[self.aapl].Price

        if aapl_price > 0:

            if self.initial_aapl_price is None:
                self.initial_aapl_price = aapl_price

            buy_hold_aapl = (
                self.initial_cash
                * aapl_price
                / self.initial_aapl_price
            )

            self.Plot(
                "Strategy Equity",
                "Portfolio Value",
                self.Portfolio.TotalPortfolioValue
            )

            self.Plot(
                "Strategy Equity",
                "Buy Hold AAPL",
                buy_hold_aapl
            )

        self.Plot(
            "Sentiment",
            "Rolling Sentiment",
            rolling_sentiment
        )

        self.Plot(
            "Sentiment",
            "Signal",
            signal
        )

        self.Plot(
            "Sentiment",
            "Articles Today",
            article_count
        )

        self.Plot(
            "Portfolio State",
            "Target AAPL Weight",
            target_weight
        )

    def GetCurrentWeight(self, symbol):

        if self.Portfolio.TotalPortfolioValue <= 0:
            return 0.0

        return (
            self.Portfolio[symbol].HoldingsValue
            / self.Portfolio.TotalPortfolioValue
        )

    def GetIntParameter(self, name, default_value):

        value = self.GetParameter(name)

        if value is None or value == "":
            return default_value

        return int(value)

    def GetFloatParameter(self, name, default_value):

        value = self.GetParameter(name)

        if value is None or value == "":
            return default_value

        return float(value)