Sentiment Signal Generator
Generate actionable trading signals from aggregated sentiment data by converting multi-source sentiment scores and trend metrics into calibrated long and short trading signals, with rigorous backtesting to validate the statistical relationship between sentiment extremes and subsequent price movements.
Sentiment Signal Generator — Sentiment & NLP
Category: Sentiment & NLP | Subcategory: Signals
What This Notebook Does
Individual sentiment scores are noisy. A single bearish headline means little; a sustained shift in overall sentiment across hundreds of articles is a genuine market signal. This notebook fuses multiple sentiment sources into a single, smoothed, actionable trading signal.
This notebook:
- Loads pre-computed sentiment scores from Reddit, Telegram, YouTube, FinBERT, and Topic Modeling notebooks
- Normalizes each signal to a common scale (−1 to +1)
- Weights and fuses signals using configurable weights per source
- Smooths the composite signal using exponential moving averages
- Generates discrete trading signals: BUY / SELL / HOLD with confidence levels
- Backtests the signal against historical OHLCV data
- Exports a clean signal DataFrame for live trading use
Signal Architecture
Reddit VADER score ─┐
Telegram VADER score ─┤
YouTube comment score ─┤── [Normalize] ── [Weighted Fusion] ── [EMA Smooth] ── Composite Signal
FinBERT news score ─┤
Topic model net signal ─┘
Why Weighted Fusion?
Different sources have different reliability and latency:
- Telegram channels often post news minutes before RSS feeds — high weight
- YouTube comments lag hours behind events — lower weight
- FinBERT on news is precise for institutional language — high weight
Prerequisites
- Notebooks 113–120 run (provides sentiment CSV files)
- Or use the synthetic data generator in Section 2 to test the pipeline
Section 1 — Install & Import
!pip install pandas numpy matplotlib seaborn scipy --quietimport pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import seaborn as sns
from scipy.stats import zscore
from datetime import datetime, timedelta, timezone
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
sns.set_palette('husl')
print('Imports ready.')Imports ready.
Section 2 — Configuration & Data Loading
Configure source weights and load sentiment CSVs exported by the upstream notebooks.
If you have not run notebooks 113–120, the synthetic data block generates realistic dummy data so you can explore the full pipeline immediately.
# ── CONFIGURATION ─────────────────────────────────────────────────────────────
SOURCE_WEIGHTS = {
'reddit': 0.20, # VADER compound scores from reddit_crypto_scraper
'telegram': 0.25, # VADER scores from telegram_channel_monitor
'youtube': 0.10, # VADER scores from youtube_sentiment_analysis
'finbert': 0.30, # FinBERT sentiment from finbert_crypto_sentiment
'topic': 0.15, # Normalized signal from topic_modeling_crypto_news
}
EMA_SPAN_FAST = 3 # fast EMA in days (reactive)
EMA_SPAN_SLOW = 7 # slow EMA in days (trend)
BUY_THRESHOLD = 0.15 # composite score above this → BUY
SELL_THRESHOLD = -0.15 # composite score below this → SELL
# File paths — update these to match your exported CSV locations
DATA_FILES = {
'reddit': 'reddit_daily_sentiment.csv',
'telegram': 'telegram_daily_sentiment.csv',
'youtube': 'youtube_daily_sentiment.csv',
'finbert': 'finbert_daily_sentiment.csv',
'topic': 'topic_modeling_crypto_news_signals.csv',
}
# ─────────────────────────────────────────────────────────────────────────────
def generate_synthetic_sentiment_data(n_days: int = 90, seed: int = 42) -> dict:
"""
Generate synthetic daily sentiment data for pipeline testing.
Parameters
----------
n_days : int
Number of days to generate.
seed : int
Random seed for reproducibility.
Returns
-------
dict
{source_name: pd.DataFrame} where each DataFrame has
columns ['date', 'sentiment_score'] with values in [-1, +1].
Notes
-----
Synthetic data contains a shared latent trend (simulating real market
sentiment) plus source-specific noise. This mimics how different platforms
reflect the same underlying mood with different signal-to-noise ratios.
"""
np.random.seed(seed)
dates = pd.date_range(end=datetime.now(tz=timezone.utc).date(), periods=n_days, freq='D')
# Shared latent trend (sine wave + random walk)
t = np.linspace(0, 4 * np.pi, n_days)
latent = 0.3 * np.sin(t) + np.cumsum(np.random.randn(n_days) * 0.02)
latent = np.clip(latent, -1, 1)
noise_levels = {'reddit': 0.25, 'telegram': 0.20, 'youtube': 0.35, 'finbert': 0.15, 'topic': 0.30}
sources = {}
for src, noise in noise_levels.items():
scores = np.clip(latent + np.random.randn(n_days) * noise, -1, 1)
sources[src] = pd.DataFrame({'date': dates, 'sentiment_score': scores})
print(f'Generated {n_days} days of synthetic sentiment data for {len(sources)} sources.')
return sources
def load_sentiment_source(filepath: str, date_col: str = 'date', score_col: str = 'sentiment_score') -> pd.DataFrame:
"""
Load a daily sentiment CSV exported by upstream notebooks.
Parameters
----------
filepath : str
Path to the CSV file.
date_col : str
Name of the date column in the CSV.
score_col : str
Name of the sentiment score column.
Returns
-------
pd.DataFrame
Cleaned DataFrame with columns ['date', 'sentiment_score'].
The 'date' column is cast to datetime.date for alignment.
"""
df = pd.read_csv(filepath, parse_dates=[date_col])
df = df.rename(columns={date_col: 'date', score_col: 'sentiment_score'})
df['date'] = pd.to_datetime(df['date']).dt.date
df = df[['date', 'sentiment_score']].dropna().sort_values('date')
return df
# ── Load or generate data ──────────────────────────────────────────────────────
USE_SYNTHETIC = True # Set False to load real CSV files
if USE_SYNTHETIC:
source_data = generate_synthetic_sentiment_data(n_days=90)
else:
source_data = {}
for src, path in DATA_FILES.items():
try:
source_data[src] = load_sentiment_source(path)
print(f'Loaded {src}: {len(source_data[src])} rows')
except FileNotFoundError:
print(f'WARNING: {path} not found. Skipping {src}.')Generated 90 days of synthetic sentiment data for 5 sources.
Section 3 — Signal Normalization
Different sources produce scores on different scales:
- VADER: −1 to +1 (already normalized)
- FinBERT: probability-based, typically 0–1 per label
- Topic net signal: integer counts, can range ±50+
We normalize everything to [−1, +1] using min-max scaling with winsorization to handle outliers.
def normalize_to_unit_range(series: pd.Series, percentile_clip: float = 1.0) -> pd.Series:
"""
Normalize a pandas Series to the range [−1, +1] with outlier clipping.
Parameters
----------
series : pd.Series
Raw sentiment score series (any numeric scale).
percentile_clip : float
Percentile for winsorization (clips top/bottom X% before scaling).
Use 1.0 to clip the top and bottom 1% — handles extreme outliers.
Returns
-------
pd.Series
Normalized series in [-1, +1]. NaN values are forward-filled.
"""
lo = np.percentile(series.dropna(), percentile_clip)
hi = np.percentile(series.dropna(), 100 - percentile_clip)
clipped = series.clip(lo, hi)
span = hi - lo
if span == 0:
return pd.Series(0.0, index=series.index)
normalized = 2 * (clipped - lo) / span - 1
return normalized.ffill()
def align_and_normalize_sources(source_data: dict) -> pd.DataFrame:
"""
Align all sentiment sources to a common daily date index and normalize scores.
Parameters
----------
source_data : dict
{source_name: pd.DataFrame} where each DataFrame has ['date', 'sentiment_score'].
Returns
-------
pd.DataFrame
Wide DataFrame indexed by date. Each column is one normalized source.
Missing days are forward-filled (last known sentiment carried forward).
Notes
-----
The alignment uses a full outer join so that no data point is discarded.
Sources with sparse data (e.g., weekly YouTube uploads) are forward-filled
to maintain alignment without introducing look-ahead bias.
"""
frames = {}
for src, df in source_data.items():
s = df.set_index('date')['sentiment_score'].rename(src)
frames[src] = s
aligned = pd.DataFrame(frames).sort_index()
# Forward fill missing values (no look-ahead bias — uses past data only)
aligned = aligned.ffill().bfill()
# Normalize each column independently
for col in aligned.columns:
aligned[col] = normalize_to_unit_range(aligned[col])
print(f'Aligned data: {len(aligned)} days, {len(aligned.columns)} sources')
print(f'Date range: {aligned.index[0]} → {aligned.index[-1]}')
return aligned
aligned_df = align_and_normalize_sources(source_data)
print(aligned_df.tail())
aligned_df.plot(title='Normalized Sentiment by Source', alpha=0.7, figsize=(14, 5))
plt.axhline(0, color='black', linewidth=0.8, linestyle='--')
plt.ylabel('Normalized Score [-1, +1]')
plt.tight_layout()
plt.show()Aligned data: 90 days, 5 sources
Date range: 2026-03-15 00:00:00 → 2026-06-12 00:00:00
reddit telegram youtube finbert topic
date
2026-06-08 -0.090309 -0.766318 -0.749139 -0.307584 -0.231005
2026-06-09 -0.325093 0.221619 -0.325235 -0.292127 -0.501079
2026-06-10 0.319742 -0.664045 -0.376384 -0.521061 -0.265552
2026-06-11 -0.312945 -0.306161 -0.116975 -0.376245 0.292643
2026-06-12 0.954268 -0.045508 -0.487915 -0.421087 -0.233702
Section 4 — Weighted Signal Fusion
We compute a weighted average of all normalized source signals. Weights reflect:
- Signal quality — sources with historically lower noise get higher weight
- Latency — faster-to-react sources get higher weight for intraday timing
- Coverage — sources with more data points are more reliable
Weights are configurable in the SOURCE_WEIGHTS dict at the top of the notebook.
def compute_weighted_composite(
aligned_df: pd.DataFrame,
weights: dict
) -> pd.Series:
"""
Compute a weighted composite sentiment signal from normalized source signals.
Parameters
----------
aligned_df : pd.DataFrame
Normalized source signals from align_and_normalize_sources().
Columns correspond to source names.
weights : dict
{source_name: weight (float)}. Weights do not need to sum to 1 —
they are re-normalized internally based on available sources.
Returns
-------
pd.Series
Daily composite score in [-1, +1]. Index is the same date index
as aligned_df.
Notes
-----
If a source is missing from aligned_df, its weight is redistributed
proportionally among available sources. This makes the function robust
to partial data availability.
"""
available = {k: v for k, v in weights.items() if k in aligned_df.columns}
total_weight = sum(available.values())
if total_weight == 0:
raise ValueError('No matching sources found in aligned DataFrame.')
composite = pd.Series(0.0, index=aligned_df.index)
for src, w in available.items():
composite += aligned_df[src] * (w / total_weight)
print(f'Composite signal computed from: {list(available.keys())}')
print(f'Effective weights: { {k: round(v/total_weight, 3) for k, v in available.items()} }')
return composite.rename('composite_raw')
def apply_ema_smoothing(
composite: pd.Series,
span_fast: int = 3,
span_slow: int = 7
) -> pd.DataFrame:
"""
Apply fast and slow exponential moving averages to the composite signal.
Parameters
----------
composite : pd.Series
Raw composite sentiment series.
span_fast : int
EMA span for the fast (reactive) signal in days.
span_slow : int
EMA span for the slow (trend) signal in days.
Returns
-------
pd.DataFrame
Columns: composite_raw, ema_fast, ema_slow, ema_crossover.
ema_crossover = ema_fast - ema_slow (positive = bullish momentum).
Notes
-----
The crossover between fast and slow EMA mirrors the classic MACD concept
applied to sentiment: when short-term sentiment rises faster than the
longer-term baseline, bullish momentum is building.
"""
df = composite.to_frame()
df['ema_fast'] = composite.ewm(span=span_fast, adjust=False).mean()
df['ema_slow'] = composite.ewm(span=span_slow, adjust=False).mean()
df['ema_crossover'] = df['ema_fast'] - df['ema_slow']
return df
composite_raw = compute_weighted_composite(aligned_df, SOURCE_WEIGHTS)
signal_df = apply_ema_smoothing(composite_raw, EMA_SPAN_FAST, EMA_SPAN_SLOW)
# Plot composite vs smoothed
fig, ax = plt.subplots(figsize=(14, 5))
ax.plot(signal_df.index, signal_df['composite_raw'], alpha=0.35, color='grey', label='Raw Composite')
ax.plot(signal_df.index, signal_df['ema_fast'], linewidth=2, color='steelblue', label=f'EMA-{EMA_SPAN_FAST}')
ax.plot(signal_df.index, signal_df['ema_slow'], linewidth=2, color='tomato', label=f'EMA-{EMA_SPAN_SLOW}')
ax.axhline(0, color='black', linewidth=0.8, linestyle='--')
ax.axhline( BUY_THRESHOLD, color='green', linewidth=0.8, linestyle=':', alpha=0.8, label='Buy threshold')
ax.axhline(SELL_THRESHOLD, color='red', linewidth=0.8, linestyle=':', alpha=0.8, label='Sell threshold')
ax.set_title('Composite Sentiment Signal with EMA Smoothing')
ax.set_ylabel('Score [-1, +1]')
ax.legend(fontsize=9)
plt.tight_layout()
plt.show()Composite signal computed from: ['reddit', 'telegram', 'youtube', 'finbert', 'topic']
Effective weights: {'reddit': 0.2, 'telegram': 0.25, 'youtube': 0.1, 'finbert': 0.3, 'topic': 0.15}
Section 5 — Discrete Signal Generation
The continuous composite score is converted to discrete BUY / SELL / HOLD signals with a confidence level based on how far the score is from the neutral zone.
def generate_discrete_signals(
signal_df: pd.DataFrame,
buy_threshold: float = 0.15,
sell_threshold: float = -0.15,
use_ema: bool = True
) -> pd.DataFrame:
"""
Convert the continuous composite score into discrete BUY / SELL / HOLD labels.
Parameters
----------
signal_df : pd.DataFrame
Output of apply_ema_smoothing(). Must contain 'ema_fast' and 'composite_raw'.
buy_threshold : float
Score above this value generates a BUY signal.
sell_threshold : float
Score below this value generates a SELL signal.
use_ema : bool
If True, uses the fast EMA for signal generation (less noisy).
If False, uses the raw composite score.
Returns
-------
pd.DataFrame
Original signal_df with added columns:
- signal: 'BUY', 'SELL', or 'HOLD'
- confidence: float in [0, 1] — how strongly the score exceeds the threshold
- signal_numeric: +1 (BUY), −1 (SELL), 0 (HOLD) for easy arithmetic
"""
score_col = 'ema_fast' if use_ema else 'composite_raw'
score = signal_df[score_col]
result = signal_df.copy()
conditions = [
score > buy_threshold,
score < sell_threshold,
]
result['signal'] = np.select(conditions, ['BUY', 'SELL'], default='HOLD')
result['signal_numeric'] = np.select(conditions, [1, -1], default=0)
# Confidence: how far above/below the threshold, scaled to [0, 1]
max_distance = 1 - abs(buy_threshold)
result['confidence'] = (
(score.abs() - abs(buy_threshold)).clip(lower=0) / max_distance
).clip(0, 1)
counts = result['signal'].value_counts()
print(f'Signals: BUY={counts.get("BUY", 0)}, SELL={counts.get("SELL", 0)}, HOLD={counts.get("HOLD", 0)}')
return result
def plot_discrete_signals(signal_df: pd.DataFrame) -> None:
"""
Visualize BUY/SELL/HOLD signals overlaid on the composite sentiment score.
Parameters
----------
signal_df : pd.DataFrame
Output of generate_discrete_signals().
"""
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)
ax = axes[0]
ax.plot(signal_df.index, signal_df['ema_fast'], color='steelblue', linewidth=2, label='EMA Fast')
ax.axhline( BUY_THRESHOLD, color='green', linewidth=0.8, linestyle='--')
ax.axhline(SELL_THRESHOLD, color='red', linewidth=0.8, linestyle='--')
ax.axhline(0, color='black', linewidth=0.5)
buys = signal_df[signal_df['signal'] == 'BUY']
sells = signal_df[signal_df['signal'] == 'SELL']
ax.scatter(buys.index, buys['ema_fast'], color='green', marker='^', s=80, zorder=5, label='BUY')
ax.scatter(sells.index, sells['ema_fast'], color='red', marker='v', s=80, zorder=5, label='SELL')
ax.set_title('Composite Sentiment Signal with BUY / SELL Markers')
ax.set_ylabel('Score')
ax.legend(fontsize=9)
ax = axes[1]
colors = signal_df['signal'].map({'BUY': 'green', 'SELL': 'red', 'HOLD': 'grey'})
ax.bar(signal_df.index, signal_df['confidence'], color=colors, alpha=0.7)
ax.set_title('Signal Confidence')
ax.set_ylabel('Confidence [0, 1]')
plt.tight_layout()
plt.show()
signal_df = generate_discrete_signals(signal_df, BUY_THRESHOLD, SELL_THRESHOLD)
plot_discrete_signals(signal_df)Signals: BUY=32, SELL=36, HOLD=22
Section 6 — Signal Backtesting
We test the signal against synthetic (or real) OHLCV data using a simple next-day entry strategy:
- BUY signal today → long from tomorrow's open
- SELL signal today → exit / short from tomorrow's open
- HOLD → maintain previous position
This is a directional accuracy test, not a full strategy backtest. For realistic backtesting with fees and slippage, see the Backtesting category notebooks.
def generate_synthetic_ohlcv(dates: pd.DatetimeIndex, seed: int = 42) -> pd.DataFrame:
"""
Generate synthetic BTC-like OHLCV data aligned to the signal dates.
Parameters
----------
dates : pd.DatetimeIndex
Date index from the signal DataFrame.
seed : int
Random seed.
Returns
-------
pd.DataFrame
OHLCV DataFrame with columns: open, high, low, close, volume.
"""
np.random.seed(seed)
n = len(dates)
log_returns = np.random.randn(n) * 0.025
close = 40000 * np.exp(np.cumsum(log_returns))
high = close * (1 + np.abs(np.random.randn(n) * 0.008))
low = close * (1 - np.abs(np.random.randn(n) * 0.008))
open_ = close * (1 + np.random.randn(n) * 0.005)
volume = np.random.exponential(1e9, n)
return pd.DataFrame({'open': open_, 'high': high, 'low': low, 'close': close, 'volume': volume}, index=dates)
def backtest_sentiment_signal(
signal_df: pd.DataFrame,
ohlcv_df: pd.DataFrame
) -> pd.DataFrame:
"""
Backtest the sentiment signal against daily OHLCV price data.
Parameters
----------
signal_df : pd.DataFrame
Signal DataFrame with 'signal_numeric' column (+1, 0, -1).
ohlcv_df : pd.DataFrame
OHLCV DataFrame aligned to the same date index.
Returns
-------
pd.DataFrame
Merged DataFrame with columns: signal_numeric, close, daily_return,
strategy_return, cumulative_market, cumulative_strategy.
Notes
-----
Strategy return = signal_numeric(t-1) * close_return(t)
The signal is shifted by one day to avoid look-ahead bias (today's signal
is acted on at tomorrow's open, proxied by tomorrow's close return).
"""
bt = ohlcv_df[['close']].copy()
bt['daily_return'] = bt['close'].pct_change()
bt['signal_numeric'] = signal_df['signal_numeric'].reindex(bt.index).ffill()
# Shift signal: today's signal applied to tomorrow's return
bt['strategy_return'] = bt['signal_numeric'].shift(1) * bt['daily_return']
bt['cumulative_market'] = (1 + bt['daily_return'].fillna(0)).cumprod()
bt['cumulative_strategy'] = (1 + bt['strategy_return'].fillna(0)).cumprod()
total_return = bt['cumulative_strategy'].iloc[-1] - 1
market_return = bt['cumulative_market'].iloc[-1] - 1
sharpe = (bt['strategy_return'].mean() / bt['strategy_return'].std()) * np.sqrt(252)
print(f'Strategy return: {total_return:.1%} | Market return: {market_return:.1%} | Sharpe: {sharpe:.2f}')
return bt
def plot_backtest_results(bt: pd.DataFrame) -> None:
"""
Plot equity curves for strategy vs buy-and-hold.
Parameters
----------
bt : pd.DataFrame
Output of backtest_sentiment_signal().
"""
fig, ax = plt.subplots(figsize=(14, 5))
ax.plot(bt.index, bt['cumulative_strategy'], linewidth=2, color='steelblue', label='Sentiment Strategy')
ax.plot(bt.index, bt['cumulative_market'], linewidth=2, color='grey', label='Buy & Hold', alpha=0.7)
ax.set_title('Sentiment Signal Strategy vs Buy-and-Hold')
ax.set_ylabel('Portfolio Value (starting at 1.0)')
ax.legend()
plt.tight_layout()
plt.show()
ohlcv_df = generate_synthetic_ohlcv(signal_df.index.to_series().apply(pd.Timestamp))
bt_df = backtest_sentiment_signal(signal_df, ohlcv_df)
plot_backtest_results(bt_df)Strategy return: -4.3% | Market return: -20.9% | Sharpe: -0.20
Section 7 — Export Signal
The final signal DataFrame is exported for use in live trading, paper trading, or further strategy notebooks.
def export_signal(
signal_df: pd.DataFrame,
filename: str = 'sentiment_signal.csv'
) -> None:
"""
Export the complete signal DataFrame to CSV.
Parameters
----------
signal_df : pd.DataFrame
Full signal DataFrame from generate_discrete_signals().
filename : str
Output CSV filename.
Outputs
-------
CSV with columns: date, composite_raw, ema_fast, ema_slow,
ema_crossover, signal, confidence, signal_numeric.
"""
out = signal_df.reset_index().rename(columns={'index': 'date'})
out.to_csv(filename, index=False)
print(f'Signal exported to {filename} ({len(out)} rows)')
print(out[['date', 'ema_fast', 'signal', 'confidence']].tail(10).to_string(index=False))
export_signal(signal_df, 'sentiment_signal.csv')Signal exported to sentiment_signal.csv (90 rows)
date ema_fast signal confidence
2026-06-03 -0.627075 SELL 0.561264
2026-06-04 -0.639794 SELL 0.576228
2026-06-05 -0.679551 SELL 0.623001
2026-06-06 -0.629596 SELL 0.564231
2026-06-07 -0.590295 SELL 0.517994
2026-06-08 -0.500888 SELL 0.412809
2026-06-09 -0.352913 SELL 0.238721
2026-06-10 -0.344383 SELL 0.228685
2026-06-11 -0.282093 SELL 0.155404
2026-06-12 -0.156395 SELL 0.007523
Summary & Next Steps
What We Built
| Step | Output |
|---|---|
| Multi-source loading | 5 sentiment streams aligned to daily index |
| Normalization | All sources on a common [-1, +1] scale |
| Weighted fusion | Single composite score |
| EMA smoothing | Fast + slow signals with crossover |
| Discrete signals | BUY / SELL / HOLD + confidence levels |
| Backtest | Equity curve vs buy-and-hold |
Improvement Ideas
- Adaptive weights: re-compute weights monthly based on realized predictive accuracy
- Regime conditioning: use higher Telegram weight in high-volatility regimes
- Confidence filter: only trade when
confidence > 0.5to reduce signal noise