Sentiment & NLP·Sentiment-Based Signals·Intermediate
Sentiment Divergence Signal
Detect statistically significant divergences between price trend direction and aggregated sentiment indicator readings as potential market reversal signals, trading the contrarian thesis that extreme unanimous sentiment readings frequently precede market turning points.
sentimentsentiment-analysissignal-generation
Sentiment Divergence Signal — Sentiment & NLP
Category: Sentiment & NLP | Subcategory: Signals
What This Notebook Does
A divergence occurs when price and sentiment move in opposite directions. These are some of the strongest contrarian signals in crypto markets:
- Bullish divergence: Price makes a new low but sentiment is rising → crowd is becoming less fearful despite lower prices → potential reversal up
- Bearish divergence: Price makes a new high but sentiment is falling → crowd is becoming skeptical despite higher prices → potential reversal down
This notebook:
- Loads the composite sentiment signal (from Notebook 121) and OHLCV price data
- Measures divergence using correlation windows, z-scores, and peak/trough detection
- Classifies divergences as bullish, bearish, or neutral
- Generates a divergence signal with configurable sensitivity
- Backtests divergence-triggered entries against the underlying asset
- Visualizes divergence episodes on price charts
The Divergence Intuition
Price: Low₁ > Low₂ (higher lows — price is not fully bearish)
Sentiment: Low₁ < Low₂ (lower lows — crowd is getting more bearish)
↓
BEARISH DIVERGENCE — price is being held up artificially; sentiment anticipates a drop
Unlike RSI divergence (which uses a technical indicator), sentiment divergence uses actual crowd psychology — making it a more direct measure of market belief vs price reality.
Prerequisites
- Notebook 121 output:
sentiment_signal.csv - Or use synthetic data (Section 2 generates it automatically)
[1]
!pip install pandas numpy matplotlib seaborn scipy --quiet[2]
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
from scipy.signal import argrelextrema
from scipy.stats import pearsonr
from datetime import datetime, 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
[3]
# ── CONFIGURATION ─────────────────────────────────────────────────────────────
DIVERGENCE_WINDOW = 14 # days to look back when measuring divergence
CORRELATION_WINDOW = 7 # rolling correlation window in days
DIVERGENCE_THRESHOLD = -0.4 # rolling correlation below this → divergence detected
MIN_HOLDING_DAYS = 3 # minimum days to hold a divergence signal
SIGNAL_FILE = 'sentiment_signal.csv' # from Notebook 121
# ─────────────────────────────────────────────────────────────────────────────
def generate_synthetic_data(n_days: int = 120, seed: int = 42) -> tuple:
"""
Generate synthetic price and sentiment data with built-in divergence episodes.
Parameters
----------
n_days : int
Number of days to simulate.
seed : int
Random seed.
Returns
-------
tuple : (price_df, sentiment_series)
price_df : pd.DataFrame with OHLCV data
sentiment_series: pd.Series with daily composite sentiment [-1, +1]
Notes
-----
Three divergence episodes are embedded:
- Days 20-35: bearish divergence (price up, sentiment down)
- Days 60-75: bullish divergence (price down, sentiment up)
- Days 90-105: bearish divergence
"""
np.random.seed(seed)
dates = pd.date_range(end=datetime.now(tz=timezone.utc).date(), periods=n_days, freq='D')
# Base price process
log_ret = np.random.randn(n_days) * 0.02
# Base sentiment correlated with price
sent = np.random.randn(n_days) * 0.15
# Inject divergence episodes: sentiment moves opposite to price
for start, end, direction in [(20, 35, -1), (60, 75, 1), (90, 105, -1)]:
trend = np.linspace(0, direction * 0.04 * (end - start), end - start)
log_ret[start:end] += trend * direction # price moves one way
sent[start:end] -= trend * direction * 2 # sentiment moves opposite
close = 40000 * np.exp(np.cumsum(log_ret))
sentiment = np.clip(np.cumsum(sent * 0.1), -1, 1)
price_df = pd.DataFrame({
'open': close * (1 + np.random.randn(n_days) * 0.004),
'high': close * (1 + np.abs(np.random.randn(n_days) * 0.008)),
'low': close * (1 - np.abs(np.random.randn(n_days) * 0.008)),
'close': close,
'volume': np.random.exponential(1e9, n_days)
}, index=dates)
sentiment_series = pd.Series(sentiment, index=dates, name='sentiment')
print(f'Generated {n_days} days of synthetic data with embedded divergence episodes.')
return price_df, sentiment_series
def load_real_data(signal_file: str, ohlcv_file: str) -> tuple:
"""
Load real sentiment signal and OHLCV data from CSV files.
Parameters
----------
signal_file : str
Path to sentiment_signal.csv from Notebook 121.
ohlcv_file : str
Path to OHLCV CSV with at minimum a 'close' column and date index.
Returns
-------
tuple : (price_df, sentiment_series)
"""
sig = pd.read_csv(signal_file, parse_dates=['date']).set_index('date')
sentiment_series = sig['ema_fast'].rename('sentiment')
price_df = pd.read_csv(ohlcv_file, parse_dates=[0], index_col=0)
common = price_df.index.intersection(sentiment_series.index)
return price_df.loc[common], sentiment_series.loc[common]
# ── Load data ─────────────────────────────────────────────────────────────────
USE_SYNTHETIC = True
if USE_SYNTHETIC:
price_df, sentiment_series = generate_synthetic_data(n_days=120)
else:
price_df, sentiment_series = load_real_data(SIGNAL_FILE, 'ohlcv.csv')Generated 120 days of synthetic data with embedded divergence episodes.
Section 3 — Divergence Detection
We use three complementary methods:
- Rolling correlation: When the short-term correlation between price returns and sentiment changes turns strongly negative, divergence is active
- Peak/trough comparison: Classic divergence analysis — compare sentiment highs/lows at price highs/lows
- Z-score spread: Standardize both series and measure the spread; extreme spread signals divergence
[4]
def compute_rolling_correlation(
price_df: pd.DataFrame,
sentiment: pd.Series,
window: int = 7
) -> pd.Series:
"""
Compute rolling Pearson correlation between daily price returns and sentiment changes.
Parameters
----------
price_df : pd.DataFrame
OHLCV DataFrame with at minimum a 'close' column.
sentiment : pd.Series
Daily composite sentiment score aligned to price_df's index.
window : int
Rolling window size in days.
Returns
-------
pd.Series
Rolling correlation in [-1, +1]. Values near -1 indicate
strong divergence (price and sentiment moving oppositely).
"""
price_ret = price_df['close'].pct_change()
sent_delta = sentiment.diff()
rolling_corr = price_ret.rolling(window).corr(sent_delta)
return rolling_corr.rename('rolling_corr')
def compute_zscore_spread(
price_df: pd.DataFrame,
sentiment: pd.Series,
window: int = 20
) -> pd.Series:
"""
Compute the z-score spread between normalized price and sentiment.
Parameters
----------
price_df : pd.DataFrame
OHLCV DataFrame.
sentiment : pd.Series
Daily sentiment score.
window : int
Lookback window for rolling z-score normalization.
Returns
-------
pd.Series
Spread series. Large positive values → price above sentiment (bearish divergence).
Large negative values → price below sentiment (bullish divergence).
"""
def rolling_zscore(s, w):
mean = s.rolling(w).mean()
std = s.rolling(w).std().clip(lower=1e-6)
return (s - mean) / std
price_z = rolling_zscore(price_df['close'], window)
sent_z = rolling_zscore(sentiment, window)
return (price_z - sent_z).rename('zscore_spread')
def detect_divergence_signal(
rolling_corr: pd.Series,
zscore_spread: pd.Series,
corr_threshold: float = -0.4,
spread_threshold: float = 1.5
) -> pd.DataFrame:
"""
Classify each day as a bullish divergence, bearish divergence, or no divergence.
Parameters
----------
rolling_corr : pd.Series
Rolling correlation from compute_rolling_correlation().
zscore_spread : pd.Series
Z-score spread from compute_zscore_spread().
corr_threshold : float
Correlation must be below this to qualify as divergence (should be negative).
spread_threshold : float
Absolute z-score spread must exceed this to confirm divergence strength.
Returns
-------
pd.DataFrame
Columns: rolling_corr, zscore_spread, divergence_type, signal_numeric.
divergence_type: 'bullish', 'bearish', or 'none'.
signal_numeric: +1 (bullish), -1 (bearish), 0 (none).
Notes
-----
Bullish divergence: correlation is low AND spread is strongly negative
(sentiment is above price on z-score scale — crowd more optimistic than price implies).
Bearish divergence: correlation is low AND spread is strongly positive.
"""
div_df = pd.DataFrame({
'rolling_corr': rolling_corr,
'zscore_spread': zscore_spread,
})
low_corr = div_df['rolling_corr'] < corr_threshold
strong_negative_spread = div_df['zscore_spread'] < -spread_threshold
strong_positive_spread = div_df['zscore_spread'] > spread_threshold
conditions = [
low_corr & strong_negative_spread,
low_corr & strong_positive_spread,
]
div_df['divergence_type'] = np.select(conditions, ['bullish', 'bearish'], default='none')
div_df['signal_numeric'] = np.select(conditions, [1, -1], default=0)
bull_days = (div_df['divergence_type'] == 'bullish').sum()
bear_days = (div_df['divergence_type'] == 'bearish').sum()
print(f'Divergence detected: {bull_days} bullish days, {bear_days} bearish days')
return div_df
# ── Run detection ─────────────────────────────────────────────────────────────
rolling_corr = compute_rolling_correlation(price_df, sentiment_series, window=CORRELATION_WINDOW)
zscore_spread = compute_zscore_spread(price_df, sentiment_series, window=DIVERGENCE_WINDOW)
div_df = detect_divergence_signal(rolling_corr, zscore_spread,
DIVERGENCE_THRESHOLD, spread_threshold=1.2)Divergence detected: 0 bullish days, 24 bearish days
Section 4 — Visualization
[5]
def plot_divergence_overview(
price_df: pd.DataFrame,
sentiment: pd.Series,
div_df: pd.DataFrame
) -> None:
"""
Four-panel chart: price, sentiment, rolling correlation, and divergence signal.
Parameters
----------
price_df : pd.DataFrame
OHLCV DataFrame.
sentiment : pd.Series
Daily composite sentiment score.
div_df : pd.DataFrame
Output of detect_divergence_signal().
"""
fig, axes = plt.subplots(4, 1, figsize=(14, 16), sharex=True)
# Panel 1: Price
ax = axes[0]
ax.plot(price_df.index, price_df['close'], color='navy', linewidth=1.5)
bull_mask = div_df['divergence_type'] == 'bullish'
bear_mask = div_df['divergence_type'] == 'bearish'
ax.fill_between(price_df.index, price_df['close'].min(), price_df['close'],
where=bull_mask, alpha=0.2, color='green', label='Bullish div.')
ax.fill_between(price_df.index, price_df['close'].min(), price_df['close'],
where=bear_mask, alpha=0.2, color='red', label='Bearish div.')
ax.set_title('Price with Divergence Episodes Highlighted')
ax.set_ylabel('Price (USD)')
ax.legend(fontsize=9)
# Panel 2: Sentiment
ax = axes[1]
ax.plot(sentiment.index, sentiment, color='steelblue', linewidth=1.5)
ax.axhline(0, color='black', linewidth=0.5, linestyle='--')
ax.set_title('Composite Sentiment Score')
ax.set_ylabel('Score [-1, +1]')
# Panel 3: Rolling Correlation
ax = axes[2]
ax.plot(div_df.index, div_df['rolling_corr'], color='purple', linewidth=1.5)
ax.axhline(DIVERGENCE_THRESHOLD, color='red', linewidth=0.8, linestyle='--', label=f'Threshold ({DIVERGENCE_THRESHOLD})')
ax.axhline(0, color='black', linewidth=0.5)
ax.set_title('Rolling Price-Sentiment Correlation')
ax.set_ylabel('Pearson r')
ax.legend(fontsize=9)
# Panel 4: Z-score spread
ax = axes[3]
colors = div_df['divergence_type'].map({'bullish': 'green', 'bearish': 'red', 'none': 'grey'})
ax.bar(div_df.index, div_df['zscore_spread'], color=colors, alpha=0.7)
ax.axhline(0, color='black', linewidth=0.5)
ax.set_title('Z-Score Spread (Price − Sentiment)')
ax.set_ylabel('Z-Score')
bull_patch = mpatches.Patch(color='green', alpha=0.7, label='Bullish')
bear_patch = mpatches.Patch(color='red', alpha=0.7, label='Bearish')
ax.legend(handles=[bull_patch, bear_patch], fontsize=9)
plt.tight_layout()
plt.show()
plot_divergence_overview(price_df, sentiment_series, div_df)Section 5 — Backtesting the Divergence Signal
[6]
def backtest_divergence_signal(
price_df: pd.DataFrame,
div_df: pd.DataFrame,
holding_period: int = 5
) -> pd.DataFrame:
"""
Backtest a divergence-triggered entry strategy.
Parameters
----------
price_df : pd.DataFrame
OHLCV DataFrame.
div_df : pd.DataFrame
Output of detect_divergence_signal().
holding_period : int
Number of days to hold a position after a divergence signal fires.
Returns
-------
pd.DataFrame
Trade log with entry_date, exit_date, direction, entry_price, exit_price, return_pct.
Notes
-----
Entry rule: on the first day of a divergence episode.
Exit rule: after holding_period days OR when divergence ends (whichever comes first).
Direction: +1 for bullish divergence entry, -1 for bearish.
"""
close = price_df['close']
signal = div_df['signal_numeric']
trades = []
in_trade = False
entry_idx = None
direction = 0
for i, (date, sig) in enumerate(signal.items()):
if not in_trade and sig != 0:
in_trade = True
entry_idx = i
direction = sig
entry_price = close.iloc[i]
elif in_trade:
days_held = i - entry_idx
if days_held >= holding_period or sig == 0:
exit_price = close.iloc[i]
ret = direction * (exit_price / entry_price - 1)
trades.append({
'entry_date': close.index[entry_idx],
'exit_date': date,
'direction': 'long' if direction == 1 else 'short',
'entry_price': round(entry_price, 2),
'exit_price': round(exit_price, 2),
'return_pct': round(ret * 100, 2),
})
in_trade = False
trade_df = pd.DataFrame(trades)
if len(trade_df):
win_rate = (trade_df['return_pct'] > 0).mean()
avg_ret = trade_df['return_pct'].mean()
print(f'Trades: {len(trade_df)} | Win rate: {win_rate:.0%} | Avg return: {avg_ret:.2f}%')
print(trade_df.to_string(index=False))
else:
print('No trades generated. Try lowering divergence thresholds.')
return trade_df
trade_log = backtest_divergence_signal(price_df, div_df, holding_period=MIN_HOLDING_DAYS)Trades: 7 | Win rate: 14% | Avg return: -102.55% entry_date exit_date direction entry_price exit_price return_pct 2026-03-07 2026-03-10 short 43600.97 70259.89 -61.14 2026-03-11 2026-03-14 short 88794.44 245810.95 -176.83 2026-03-15 2026-03-18 short 372821.61 1771494.08 -375.16 2026-03-19 2026-03-22 short 3281413.27 3092071.33 5.77 2026-03-23 2026-03-26 short 3011015.75 3078404.37 -2.24 2026-04-16 2026-04-19 short 3282766.86 5598079.09 -70.53 2026-04-20 2026-04-21 short 7229184.05 9956213.53 -37.72
Section 6 — Export
[7]
def export_divergence_results(
div_df: pd.DataFrame,
trade_log: pd.DataFrame,
prefix: str = 'sentiment_divergence'
) -> None:
"""
Export divergence signal and trade log to CSV files.
Parameters
----------
div_df : pd.DataFrame
Daily divergence signal DataFrame.
trade_log : pd.DataFrame
Trade log from backtest_divergence_signal().
prefix : str
Filename prefix for output files.
"""
signal_out = f'{prefix}_signal.csv'
trades_out = f'{prefix}_trades.csv'
div_df.to_csv(signal_out)
if len(trade_log):
trade_log.to_csv(trades_out, index=False)
print(f'Exported: {signal_out}')
print(f'Exported: {trades_out}')
export_divergence_results(div_df, trade_log)Exported: sentiment_divergence_signal.csv Exported: sentiment_divergence_trades.csv
Summary & Next Steps
What We Built
| Step | Output |
|---|---|
| Rolling correlation | Price-sentiment directional alignment daily |
| Z-score spread | Magnitude of price vs sentiment gap |
| Divergence classifier | Bullish / bearish / none labels per day |
| Trade backtest | Entry/exit trades with P&L |
Improvements
- Add a minimum episode length filter: only fire signal if divergence persists for 3+ days
- Combine with volume confirmation: divergence + volume spike = higher conviction
- Use ATR-based stops rather than fixed holding periods