Alternative Data and NLP in Quant Strategies › Building a News Sentiment Trading Signal

Michael BrenndoerferJanuary 4, 202610 min read
Quantitative FinanceAll chapters
Part 13 of 15Machine Learning and Alternative Data
Chapter 71Alternative Data and NLP in Quant Strategies › Building a News Sentiment Trading Signal
Reading section 10 of 18 · Building a News Sentiment Trading Signal · 10 minSection 10 of 18 · 10 min

Book contents

Section 10 of 18 · 10 min read

Alternative Data and NLP in Quant Strategies › Building a News Sentiment Trading SignalOne section · Sections in this chapter10 / 18 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's build a complete pipeline that converts financial news into a tradable sentiment signal. This example demonstrates the practical workflow from raw text to portfolio positioning.

In[20]:
Code
import numpy as np
import pandas as pd

# Simulate news feed data
np.random.seed(42)

# Generate synthetic news data for multiple stocks
tickers = ["AAPL", "GOOGL", "MSFT", "AMZN", "META"]
dates = pd.date_range(start="2023-01-01", end="2023-12-31", freq="B")

news_templates = {
    "positive": [
        "{ticker} beats earnings estimates, guidance raised",
        "{ticker} announces major partnership, shares surge",
        "{ticker} reports strong revenue growth in key segment",
        "Analysts upgrade {ticker} citing improved fundamentals",
        "{ticker} expands into new market with positive reception",
    ],
    "negative": [
        "{ticker} misses quarterly targets, cuts guidance",
        "{ticker} faces regulatory scrutiny over practices",
        "{ticker} reports declining margins amid competition",
        "Analysts downgrade {ticker} on execution concerns",
        "{ticker} announces restructuring and layoffs",
    ],
    "neutral": [
        "{ticker} reports results in line with expectations",
        "{ticker} maintains guidance for fiscal year",
        "{ticker} announces routine executive changes",
        "{ticker} schedules investor day presentation",
        "{ticker} completes previously announced acquisition",
    ],
}

# Generate news events
news_data = []
for date in dates:
    # Each day, 0-3 news items per stock
    for ticker in tickers:
        num_news = np.random.choice(
            [0, 0, 0, 1, 1, 2], p=[0.3, 0.2, 0.2, 0.15, 0.1, 0.05]
        )
        for _ in range(num_news):
            sentiment_type = np.random.choice(
                ["positive", "negative", "neutral"], p=[0.35, 0.35, 0.30]
            )
            template = np.random.choice(news_templates[sentiment_type])
            headline = template.format(ticker=ticker)

            news_data.append(
                {
                    "date": date,
                    "ticker": ticker,
                    "headline": headline,
                    "true_sentiment": sentiment_type,
                }
            )

news_df = pd.DataFrame(news_data)
Out[21]:
Console
Sample News Data:
      date ticker                                              headline true_sentiment
2023-01-02  GOOGL      GOOGL completes previously announced acquisition        neutral
2023-01-02  GOOGL        GOOGL faces regulatory scrutiny over practices       negative
2023-01-02   META       META reports declining margins amid competition       negative
2023-01-03  GOOGL              GOOGL maintains guidance for fiscal year        neutral
2023-01-03  GOOGL GOOGL expands into new market with positive reception       positive
2023-01-04   META     META reports strong revenue growth in key segment       positive
2023-01-04   META         Analysts downgrade META on execution concerns       negative
2023-01-06   AAPL               AAPL maintains guidance for fiscal year        neutral
2023-01-09   MSFT        MSFT beats earnings estimates, guidance raised       positive
2023-01-10   META               META maintains guidance for fiscal year        neutral

Total news items: 459
News items by sentiment:
true_sentiment
positive    168
negative    163
neutral     128
Name: count, dtype: int64

The sample shows the structure of our simulated news feed with dates, tickers, headlines, and true sentiment labels. The distribution across positive, negative, and neutral sentiment is roughly balanced, and gives a realistic test dataset. We'll now process all news through our sentiment model and aggregate to daily stock-level signals.

In[22]:
Code
def compute_sentiment_score(headline, model):
    """Convert headline to numerical sentiment score."""
    probs = model.analyze(headline)
    # Score: positive minus negative (ranges from -1 to +1)
    return probs["positive"] - probs["negative"]


# Apply sentiment analysis to all headlines
news_df["sentiment_score"] = news_df["headline"].apply(
    lambda x: compute_sentiment_score(x, finbert)
)

# Aggregate to daily sentiment per stock
daily_sentiment = (
    news_df.groupby(["date", "ticker"])
    .agg({"sentiment_score": ["mean", "count", "std"]})
    .reset_index()
)

# Flatten column names
daily_sentiment.columns = [
    "date",
    "ticker",
    "avg_sentiment",
    "news_count",
    "sentiment_std",
]
daily_sentiment["sentiment_std"] = daily_sentiment["sentiment_std"].fillna(0)


# Create sentiment signal with decay
def compute_signal_with_decay(df, halflife=5):
    """Compute exponentially weighted sentiment signal."""
    df = df.sort_values("date")

    # Fill missing dates with zero sentiment
    full_dates = pd.date_range(df["date"].min(), df["date"].max(), freq="B")
    df = (
        df.set_index("date")
        .reindex(full_dates)
        .fillna({"avg_sentiment": 0, "news_count": 0})
    )

    # Exponential decay weighting, alpha controls decay rate
    # With halflife=5, signals decay by 50% after 5 days
    alpha = 1 - np.exp(-np.log(2) / halflife)
    df["signal"] = df["avg_sentiment"].ewm(alpha=alpha, adjust=False).mean()

    return df.reset_index().rename(columns={"index": "date"})


# Compute signals for each ticker
signals = []
for ticker in tickers:
    ticker_data = daily_sentiment[daily_sentiment["ticker"] == ticker].copy()
    ticker_signal = compute_signal_with_decay(ticker_data)
    ticker_signal["ticker"] = ticker
    signals.append(ticker_signal)

signal_df = pd.concat(signals, ignore_index=True)
Out[23]:
Console
Sentiment Signal Summary by Ticker:
--------------------------------------------------
         mean    std    min    max
ticker                            
AAPL    0.032  0.044 -0.098  0.144
AMZN   -0.008  0.091 -0.600  0.200
GOOGL   0.029  0.048 -0.088  0.221
META    0.017  0.048 -0.141  0.154
MSFT    0.009  0.098 -0.209  0.600

The summary statistics reveal how sentiment signals vary across stocks. The mean values hover near zero because the simulated news generator produces roughly balanced positive and negative headlines over time; dollar neutrality is imposed only later, when the signals are converted into portfolio weights. Standard deviations around 0.1-0.2 indicate moderate variation in sentiment, with extremes ranging from approximately -0.4 to +0.4.

Out[24]:
Visualization
Line chart with date on the x-axis and sentiment signal on the y-axis; five colored lines for AAPL, GOOGL, MSFT, AMZN, and META oscillate above and below a dashed zero reference line throughout 2023.
Daily sentiment signals for five technology stocks in 2023, computed with exponentially weighted moving averages and a five-day halflife. The simulated news generator produces broadly balanced positive and negative headlines, so the smoothed series move around zero. Cross-sectional differences identify relative sentiment opportunities; dollar neutrality is introduced only when these signals are converted into portfolio weights.

The time series visualization reveals how sentiment signals evolve across different stocks throughout the year. The signals oscillate around zero, which reflects the balance between positive and negative news flow. This pattern is expected from our construction where sentiment scores are normalized to have approximately zero mean. Periods where a stock's line rises above zero indicate accumulating positive sentiment, while dips below zero signal negative news predominance. The crossing patterns show how relative sentiment rankings change: stocks that outperformed sentiment-wise earlier may underperform later. The exponential decay smoothing prevents signals from reacting too sharply to individual news items while still capturing sustained sentiment trends. These dynamic patterns form the basis for constructing market-neutral long-short portfolios.

The sentiment signal exhibits the classic characteristics of alpha factors: mean-reverting oscillations around zero with occasional persistent trends. The exponential weighting ensures recent news has more impact while allowing sentiment to decay over time if no new information arrives.

Converting Signals to Positions

Following our discussion of factor investing from Chapter 4, we can convert sentiment signals into portfolio weights. The challenge arises because raw sentiment signals have different scales across assets and time periods, making direct comparison difficult. A sentiment score of 0.3 for one stock might represent a strong signal, while the same value for another stock with more volatile sentiment might be unremarkable. You need to standardize signals to a common scale to compare their strength fairly across assets.

Z-score normalization addresses this by standardizing signals to have zero mean and unit variance within each cross-section. The transformation expresses each signal as the number of standard deviations from the cross-sectional average, creating a uniform scale for meaningful comparison across assets. This approach is essential for portfolios where we allocate capital based on relative signal strength rather than absolute signal values.

The z-score transformation centers signals around the cross-sectional mean and scales them by standard deviation:

zi=si−sˉσsz_i = \frac{s_i - \bar{s}}{\sigma_s}

where:

  • ii: index identifying a specific asset in the portfolio universe
  • sis_i: raw sentiment signal for asset ii (from our earlier computations)
  • sˉ\bar{s}: mean sentiment across all assets, computed as sˉ=1N∑j=1Nsj\bar{s} = \frac{1}{N}\sum_{j=1}^{N} s_j where NN is the number of assets
  • σs\sigma_s: standard deviation of sentiment signals across assets, computed as σs=1N∑j=1N(sj−sˉ)2\sigma_s = \sqrt{\frac{1}{N}\sum_{j=1}^{N}(s_j - \bar{s})^2}, measuring dispersion
  • ziz_i: standardized z-score for asset ii, representing how many standard deviations sis_i is from the mean

This step centers signals around zero (subtracting the mean) and scales them to a common standard deviation (dividing by σs\sigma_s). The centering step ensures that the average z-score across all assets is exactly zero, which is a prerequisite for constructing dollar-neutral portfolios. The scaling step normalizes the dispersion so that a z-score of +1 always means "one standard deviation above average" regardless of the underlying signal's natural scale. Assets with above-average sentiment receive positive z-scores, while those with below-average sentiment receive negative z-scores. The magnitude indicates how many standard deviations the signal is from average, and gives an intuitive measure of signal strength.

After computing z-scores, you convert them into portfolio weights. Unconstrained z-scores could lead to extreme leverage if used directly. A stock with a z-score of 3 would receive three times the allocation of a stock with a z-score of 1, and the total gross exposure would depend on the particular z-score distribution on each day. We solve this by normalizing so that the total gross exposure (sum of absolute weights) equals 1 (or 100%), creating a controlled leverage profile.

The portfolio weight for each asset is computed as:

wi=zi∑j∣zj∣w_i = \frac{z_i}{\sum_j |z_j|}

where:

  • ii: index for the asset being weighted
  • jj: index running over all assets in the portfolio universe
  • ziz_i: standardized z-score for asset ii (from the previous formula)
  • wiw_i: portfolio weight for asset ii (can be positive for long or negative for short positions)
  • ∣zj∣|z_j|: absolute value of z-score for asset jj
  • ∑j∣zj∣\sum_j |z_j|: sum of absolute z-scores across all assets, normalizing total gross exposure to 1

This normalization constrains gross exposure (sum of absolute weights) to exactly 1, preventing excessive leverage while preserving the relative ranking of signals. The denominator sums absolute values rather than raw values because we want to account for both long and short positions when computing total exposure. Assets with positive z-scores receive long positions proportional to their signal strength, while negative z-scores generate short positions. The absolute value denominator ensures both long and short legs contribute to the exposure constraint, creating a balanced long-short portfolio. Because the z-scores were already centered around zero, the sum of positive z-scores approximately equals the sum of absolute negative z-scores, resulting in a portfolio that is naturally dollar-neutral.

In[25]:
Code
import pandas as pd


def sentiment_to_weights(signal_df, date, method="zscore"):
    """Convert sentiment signals to portfolio weights."""
    # Get signals for the date
    current = signal_df[signal_df["date"] == date][["ticker", "signal"]].copy()

    if len(current) == 0:
        return None

    if method == "zscore":
        # Z-score normalization: long positive z-scores, short negative
        mean_signal = current["signal"].mean()
        std_signal = current["signal"].std()
        if std_signal > 0:
            current["zscore"] = (current["signal"] - mean_signal) / std_signal
        else:
            current["zscore"] = 0

        # Winsorize extreme values
        current["zscore"] = current["zscore"].clip(-3, 3)

        # Convert to weights (equal notional exposure)
        total_abs = current["zscore"].abs().sum()
        if total_abs > 0:
            current["weight"] = current["zscore"] / total_abs
        else:
            current["weight"] = 0

    elif method == "rank":
        # Rank-based, simpler and more robust
        current["rank"] = current["signal"].rank()
        n = len(current)
        current["weight"] = (current["rank"] - (n + 1) / 2) / n

    return current[["ticker", "weight"]]


# Compute weights for a sample date
sample_date = pd.Timestamp("2023-06-15")
weights = sentiment_to_weights(signal_df, sample_date, method="zscore")
Out[26]:
Console
Portfolio Weights for 2023-06-15:
----------------------------------------
  AAPL: -19.41% (SHORT)
  GOOGL: +23.09% (LONG)
  MSFT: -30.59% (SHORT)
  AMZN: +20.13% (LONG)
  META: +6.78% (LONG)

Net exposure: 0.0000
Gross exposure: 1.0000

The resulting portfolio is dollar-neutral: with net exposure of 0.0000 (effectively zero), it positions stocks according to relative sentiment. The gross exposure of 1.0000 indicates we are fully invested long and short in equal notional amounts. Stocks with the most positive sentiment receive long weights, while those with negative sentiment get short weights. This follows the long-short factor portfolio construction we covered in Part IV.

Out[27]:
Visualization
Horizontal bar chart of five stock portfolio weights. Teal long-position bars extend right of zero and rose short-position bars extend left, with each bar labeled by its percentage weight.
Market-neutral sentiment portfolio weights for June 15, 2023, using cross-sectional z-score normalization. Teal bars are long positions in stocks with above-average sentiment, and rose bars are short positions in stocks with below-average sentiment. The weights sum to zero net exposure and 100 percent gross exposure.