Reddit Crypto Scraper
Scrape cryptocurrency subreddit communities using the Reddit API, systematically collecting post titles, body text, comment threads, upvote and downvote scores, and community sentiment indicators to gauge retail investor sentiment and identify emerging narrative trends.
Reddit Crypto Scraper — Sentiment & NLP
Category: Sentiment & NLP | Subcategory: Social
What This Notebook Does
This notebook builds a complete pipeline to:
- Scrape posts and comments from crypto-focused Reddit communities using the PRAW library
- Preprocess raw text — clean noise, tokenize, and normalize
- Analyze sentiment using VADER (rule-based) and TextBlob (ML-based)
- Visualize sentiment distributions, trends over time, and keyword clouds
- Export structured results to CSV for downstream use in trading strategies
Why Reddit for Crypto?
Retail sentiment on platforms like Reddit often precedes price movements in crypto markets. By measuring the mood of communities such as r/CryptoCurrency and r/Bitcoin, traders can build leading indicators that complement technical analysis.
Prerequisites
- A free Reddit Developer Account to get API credentials
- Basic Python knowledge (variables, loops, functions)
Tip: Run cells top-to-bottom. Each section builds on the previous one.
Section 1 — Install & Import Dependencies
praw is the Python Reddit API Wrapper — it handles authentication, rate-limiting, and pagination automatically. We also install:
vaderSentiment— optimized for short, informal social media texttextblob— simple ML-based polarity and subjectivity scorerwordcloud— for visual keyword analysis- Standard data science stack:
pandas,matplotlib,seaborn
# Install required libraries (Colab resets on each session)
!pip install praw vaderSentiment textblob wordcloud --quiet[?25l [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m0.0/199.2 kB[0m [31m?[0m eta [36m-:--:--[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m199.2/199.2 kB[0m [31m8.3 MB/s[0m eta [36m0:00:00[0m [?25h[?25l [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m0.0/126.0 kB[0m [31m?[0m eta [36m-:--:--[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m126.0/126.0 kB[0m [31m8.9 MB/s[0m eta [36m0:00:00[0m [?25h[?25l [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m0.0/73.1 kB[0m [31m?[0m eta [36m-:--:--[0m [2K [90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[0m [32m73.1/73.1 kB[0m [31m4.5 MB/s[0m eta [36m0:00:00[0m [?25h[31mERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts. google-colab 1.0.0 requires requests==2.32.4, but you have requests 2.34.2 which is incompatible.[0m[31m [0m
import praw
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import seaborn as sns
from wordcloud import WordCloud
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
from textblob import TextBlob
import re
import string
from datetime import datetime, timezone
from collections import Counter
# Notebook display settings
%matplotlib inline
plt.rcParams['figure.figsize'] = (12, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
sns.set_palette('husl')
print('All imports successful.')All imports successful.
Section 2 — Reddit API Authentication
Reddit uses OAuth2. You need three credentials from your app dashboard:
client_id— shown under your app name (14-character string)client_secret— the secret keyuser_agent— a descriptive string identifying your script
How to get credentials:
- Go to https://www.reddit.com/prefs/apps
- Click "create another app"
- Select script, fill in any name and redirect URI (
http://localhost) - Copy
client_id(below the app name) andclient_secret
Security Note: Never hard-code credentials in shared notebooks. Use Colab's Secrets manager (
🔑icon in the left sidebar) or environment variables.
import random
from datetime import datetime, timedelta, timezone
# ─── CONFIGURATION ────────────────────────────────────────────────────────────
# Toggle this to True to use synthetic data instead of calling the Reddit API
USE_DUMMY_DATA = True
# Replace these with your actual credentials (ignored if USE_DUMMY_DATA is True)
REDDIT_CLIENT_ID = 'YOUR_CLIENT_ID'
REDDIT_CLIENT_SECRET = 'YOUR_CLIENT_SECRET'
REDDIT_USER_AGENT = 'crypto_scraper/1.0 by YourRedditUsername'
# Subreddits to scrape
TARGET_SUBREDDITS = ['CryptoCurrency', 'Bitcoin', 'ethereum', 'CryptoMarkets', 'altcoin']
# How many posts to fetch per subreddit
POSTS_LIMIT = 100
# Post sort method: 'hot', 'new', 'top', 'rising'
SORT_METHOD = 'hot'
# ──────────────────────────────────────────────────────────────────────────────
def create_reddit_client(client_id: str, client_secret: str, user_agent: str) -> praw.Reddit:
if USE_DUMMY_DATA:
print('Mode: Dummy Data Enabled. Skipping Reddit Authentication.')
return None
reddit = praw.Reddit(
client_id=client_id,
client_secret=client_secret,
user_agent=user_agent,
read_only=True
)
print(f'Reddit client created. Read-only: {reddit.read_only}')
return reddit
reddit = create_reddit_client(REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USER_AGENT)Mode: Dummy Data Enabled. Skipping Reddit Authentication.
Section 3 — Scraping Posts
PRAW's .hot(), .new(), and .top() methods return generator objects — they lazily fetch pages from Reddit's API, respecting the rate limit of 60 requests per minute automatically.
For each post we capture:
| Field | Description |
|---|---|
title | Post headline — often the most sentiment-rich field |
selftext | Post body text |
score | Net upvotes (upvotes − downvotes) |
upvote_ratio | Fraction of votes that are upvotes |
num_comments | Engagement signal |
created_utc | Unix timestamp → converted to datetime |
subreddit | Source community |
def generate_dummy_data(subreddit_name: str, limit: int) -> list[dict]:
"""Generates synthetic Reddit-like data for testing."""
templates = [
("Bitcoin is going to the moon! 🚀", "I just bought the dip and feeling great. HODL!"),
("Is Ethereum a good investment?", "Thinking about moving my bags from BTC to ETH. Thoughts?"),
("Market crash is coming...", "Seeing a lot of red candles. Might be time to exit for a while. Very bearish."),
("New altcoin gem discovered!", "Check out this new project. Great fundamentals and community."),
("Regulations are killing crypto", "Government is making it harder for us. This is bad news.")
]
dummy_posts = []
for i in range(limit):
title, body = random.choice(templates)
# Randomize dates over the last 7 days
random_days = random.randint(0, 7)
created_date = datetime.now(timezone.utc) - timedelta(days=random_days)
dummy_posts.append({
'post_id': f'dummy_{subreddit_name}_{i}',
'subreddit': subreddit_name,
'title': f"[{i}] {title}",
'selftext': body,
'score': random.randint(1, 2000),
'upvote_ratio': random.uniform(0.5, 0.99),
'num_comments': random.randint(5, 500),
'created_utc': created_date,
'url': 'https://www.reddit.com',
'author': 'crypto_enthusiast',
'is_self': True,
'flair': 'Discussion'
})
return dummy_posts
def scrape_subreddit_posts(reddit_client, subreddit_name, limit=100, sort='hot'):
if USE_DUMMY_DATA:
posts = generate_dummy_data(subreddit_name, limit)
print(f' r/{subreddit_name}: {len(posts)} dummy posts generated')
return posts
subreddit = reddit_client.subreddit(subreddit_name)
feed = {'hot': subreddit.hot, 'new': subreddit.new, 'top': subreddit.top, 'rising': subreddit.rising}.get(sort, subreddit.hot)
posts = []
for submission in feed(limit=limit):
posts.append({
'post_id': submission.id,
'subreddit': subreddit_name,
'title': submission.title,
'selftext': submission.selftext,
'score': submission.score,
'upvote_ratio': submission.upvote_ratio,
'num_comments': submission.num_comments,
'created_utc': datetime.fromtimestamp(submission.created_utc, tz=timezone.utc),
'url': submission.url,
'author': str(submission.author),
'is_self': submission.is_self,
'flair': submission.link_flair_text
})
print(f' r/{subreddit_name}: {len(posts)} posts fetched')
return posts
def scrape_multiple_subreddits(reddit_client, subreddit_list, limit=100, sort='hot'):
all_posts = []
mode_str = "DUMMY MODE" if USE_DUMMY_DATA else "API MODE"
print(f'Scraping {len(subreddit_list)} subreddits ({mode_str}, {sort}, limit={limit})...')
for sub in subreddit_list:
try:
posts = scrape_subreddit_posts(reddit_client, sub, limit, sort)
all_posts.extend(posts)
except Exception as e:
print(f' WARNING: Could not scrape r/{sub} — {e}')
df = pd.DataFrame(all_posts)
if not df.empty:
df.drop_duplicates(subset='post_id', inplace=True)
df.sort_values('created_utc', ascending=False, inplace=True)
df.reset_index(drop=True, inplace=True)
print(f'\nTotal posts collected: {len(df)}')
return df
raw_df = scrape_multiple_subreddits(reddit, TARGET_SUBREDDITS, POSTS_LIMIT, SORT_METHOD)
display(raw_df.head(3))Scraping 5 subreddits (DUMMY MODE, hot, limit=100)... r/CryptoCurrency: 100 dummy posts generated r/Bitcoin: 100 dummy posts generated r/ethereum: 100 dummy posts generated r/CryptoMarkets: 100 dummy posts generated r/altcoin: 100 dummy posts generated Total posts collected: 500
| post_id | subreddit | title | selftext | score | upvote_ratio | num_comments | created_utc | url | author | is_self | flair | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | dummy_altcoin_81 | altcoin | [81] Market crash is coming... | Seeing a lot of red candles. Might be time to ... | 1450 | 0.592963 | 129 | 2026-06-16 07:01:22.767959+00:00 | https://www.reddit.com | crypto_enthusiast | True | Discussion |
| 1 | dummy_altcoin_72 | altcoin | [72] Is Ethereum a good investment? | Thinking about moving my bags from BTC to ETH.... | 1606 | 0.658905 | 300 | 2026-06-16 07:01:22.767897+00:00 | https://www.reddit.com | crypto_enthusiast | True | Discussion |
| 2 | dummy_altcoin_69 | altcoin | [69] New altcoin gem discovered! | Check out this new project. Great fundamentals... | 1702 | 0.975562 | 434 | 2026-06-16 07:01:22.767876+00:00 | https://www.reddit.com | crypto_enthusiast | True | Discussion |
Section 4 — Text Preprocessing
Raw Reddit text is noisy. Before running sentiment analysis we need to:
- Remove URLs — they carry no sentiment signal
- Strip Reddit markdown (e.g.,
**bold**,>quote) - Remove special characters and excess whitespace
- Combine the title and body — the title is the headline and is weighted higher in community engagement
Why not remove stop words here? VADER and TextBlob are designed to work on natural language including words like "not", "very", and "but" — removing them would break the sentiment scoring. Stop words are only removed for the word cloud visualization.
# Common crypto stop words to exclude from word clouds (not from sentiment analysis)
WORDCLOUD_STOP_WORDS = {
'crypto', 'bitcoin', 'btc', 'eth', 'ethereum', 'coin', 'coins',
'market', 'price', 'just', 'like', 'get', 'one', 'will', 'think',
'know', 'people', 'time', 'new', 'good', 'going', 'much', 'also',
'still', 'even', 'want', 'see', 'make', 'use', 'year', 'really',
'many', 'right', 'said', 'say', 'back', 'way', 'something', 'every',
'subreddit', 'post', 'comment', 'reddit', 'https', 'com', 'www'
}
def clean_text(text: str) -> str:
"""
Clean raw Reddit text for sentiment analysis.
Removes URLs, markdown formatting, special characters, and extra whitespace.
Preserves natural language structure needed by VADER and TextBlob.
Parameters
----------
text : Raw text string
Returns
-------
Cleaned text string
"""
if not isinstance(text, str):
return ''
text = re.sub(r'http\S+|www\.\S+', '', text) # remove URLs
text = re.sub(r'>.*?\n', ' ', text) # remove Reddit quotes
text = re.sub(r'[*_~`#]', '', text) # remove markdown symbols
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) # keep link text
text = re.sub(r'\s+', ' ', text) # normalize whitespace
return text.strip()
def combine_title_body(title: str, body: str, title_weight: int = 2) -> str:
"""
Combine post title and body into a single analysis string.
The title is repeated `title_weight` times to reflect its higher
importance — it's what users read first and influences upvote behavior.
Parameters
----------
title : Post title text
body : Post body (selftext)
title_weight : How many times to repeat the title (default=2)
Returns
-------
Combined, cleaned text string
"""
clean_title = clean_text(title)
clean_body = clean_text(body)
repeated_title = ' '.join([clean_title] * title_weight)
combined = f'{repeated_title} {clean_body}'.strip()
return combined
def preprocess_dataframe(df: pd.DataFrame) -> pd.DataFrame:
"""
Apply all preprocessing steps to the raw scraped DataFrame.
Adds columns: 'clean_title', 'clean_body', 'combined_text'.
Parameters
----------
df : Raw DataFrame from scrape_multiple_subreddits()
Returns
-------
DataFrame with new text columns added
"""
df = df.copy()
df['clean_title'] = df['title'].apply(clean_text)
df['clean_body'] = df['selftext'].apply(clean_text)
df['combined_text'] = df.apply(
lambda row: combine_title_body(row['title'], row['selftext']), axis=1
)
df['text_length'] = df['combined_text'].apply(len)
print(f'Preprocessing complete. Avg text length: {df["text_length"].mean():.0f} chars')
return df
processed_df = preprocess_dataframe(raw_df)
processed_df[['title', 'clean_title', 'combined_text', 'text_length']].head(3)Preprocessing complete. Avg text length: 128 chars
| title | clean_title | combined_text | text_length | |
|---|---|---|---|---|
| 0 | [81] Market crash is coming... | [81] Market crash is coming... | [81] Market crash is coming... [81] Market cra... | 139 |
| 1 | [72] Is Ethereum a good investment? | [72] Is Ethereum a good investment? | [72] Is Ethereum a good investment? [72] Is Et... | 128 |
| 2 | [69] New altcoin gem discovered! | [69] New altcoin gem discovered! | [69] New altcoin gem discovered! [69] New altc... | 127 |
Section 5 — Sentiment Analysis
We use two complementary models:
VADER (Valence Aware Dictionary and sEntiment Reasoner)
- Specifically designed for social media text
- Handles capitalization (
GREATscores higher thangreat), punctuation (!!!), and slang - Returns four scores:
neg,neu,pos, andcompound(−1 to +1) - Best for: Short, informal crypto posts
TextBlob
- Pattern-based ML approach trained on movie reviews
- Returns
polarity(−1 to +1) andsubjectivity(0=objective, 1=subjective) - Best for: Longer, more structured post bodies
Labeling Logic
compound ≥ 0.05 → POSITIVE (bullish)
compound ≤ -0.05 → NEGATIVE (bearish)
otherwise → NEUTRAL
vader = SentimentIntensityAnalyzer()
def get_vader_scores(text: str) -> dict:
"""
Compute VADER sentiment scores for a text string.
Parameters
----------
text : Cleaned text to analyze
Returns
-------
Dictionary with keys: vader_neg, vader_neu, vader_pos, vader_compound
"""
if not text or len(text) < 3:
return {'vader_neg': 0.0, 'vader_neu': 1.0, 'vader_pos': 0.0, 'vader_compound': 0.0}
scores = vader.polarity_scores(text)
return {
'vader_neg': scores['neg'],
'vader_neu': scores['neu'],
'vader_pos': scores['pos'],
'vader_compound': scores['compound']
}
def get_textblob_scores(text: str) -> dict:
"""
Compute TextBlob sentiment scores for a text string.
Parameters
----------
text : Cleaned text to analyze
Returns
-------
Dictionary with keys: tb_polarity, tb_subjectivity
"""
if not text or len(text) < 3:
return {'tb_polarity': 0.0, 'tb_subjectivity': 0.0}
blob = TextBlob(text)
return {
'tb_polarity': blob.sentiment.polarity,
'tb_subjectivity': blob.sentiment.subjectivity
}
def label_sentiment(compound_score: float, positive_threshold: float = 0.05, negative_threshold: float = -0.05) -> str:
"""
Convert a VADER compound score into a categorical label.
Parameters
----------
compound_score : VADER compound value in range [-1, 1]
positive_threshold : Minimum compound score for POSITIVE label
negative_threshold : Maximum compound score for NEGATIVE label
Returns
-------
'POSITIVE', 'NEGATIVE', or 'NEUTRAL'
"""
if compound_score >= positive_threshold:
return 'POSITIVE'
elif compound_score <= negative_threshold:
return 'NEGATIVE'
return 'NEUTRAL'
def run_sentiment_analysis(df: pd.DataFrame) -> pd.DataFrame:
"""
Apply VADER and TextBlob sentiment analysis to all posts in the DataFrame.
Adds columns: vader_neg, vader_neu, vader_pos, vader_compound,
tb_polarity, tb_subjectivity, sentiment_label,
combined_polarity (average of VADER compound and TB polarity)
Parameters
----------
df : Preprocessed DataFrame with 'combined_text' column
Returns
-------
DataFrame with sentiment score columns added
"""
df = df.copy()
# Apply VADER
vader_scores = df['combined_text'].apply(get_vader_scores)
df = pd.concat([df, pd.DataFrame(list(vader_scores))], axis=1)
# Apply TextBlob
tb_scores = df['combined_text'].apply(get_textblob_scores)
df = pd.concat([df, pd.DataFrame(list(tb_scores))], axis=1)
# Categorical label based on VADER compound
df['sentiment_label'] = df['vader_compound'].apply(label_sentiment)
# Ensemble polarity: average of VADER compound and TextBlob polarity
df['combined_polarity'] = (df['vader_compound'] + df['tb_polarity']) / 2
label_counts = df['sentiment_label'].value_counts()
print('Sentiment distribution:')
for label, count in label_counts.items():
pct = count / len(df) * 100
print(f' {label:10s}: {count:4d} ({pct:.1f}%)')
return df
sentiment_df = run_sentiment_analysis(processed_df)
sentiment_df[['title', 'vader_compound', 'tb_polarity', 'tb_subjectivity', 'sentiment_label']].head(5)Sentiment distribution: POSITIVE : 283 (56.6%) NEGATIVE : 217 (43.4%)
| title | vader_compound | tb_polarity | tb_subjectivity | sentiment_label | |
|---|---|---|---|---|---|
| 0 | [81] Regulations are killing crypto | -0.9231 | -0.400000 | 0.333333 | NEGATIVE |
| 1 | [80] Bitcoin is going to the moon! 🚀 | 0.7562 | 1.000000 | 0.750000 | POSITIVE |
| 2 | [35] Is Ethereum a good investment? | 0.7461 | 0.700000 | 0.600000 | POSITIVE |
| 3 | [30] New altcoin gem discovered! | 0.6892 | 0.319318 | 0.528409 | POSITIVE |
| 4 | [28] Is Ethereum a good investment? | 0.7461 | 0.700000 | 0.600000 | POSITIVE |
Section 6 — Visualization
Good data visualization serves two purposes:
- Exploration — quickly spot patterns in the data
- Communication — share findings with collaborators or embed in dashboards
We'll create four charts:
- Sentiment distribution — overall bullish/bearish/neutral balance
- Compound score distribution — continuous view of sentiment intensity
- Sentiment by subreddit — compare community moods
- Word clouds — most frequent words in positive vs. negative posts
def plot_sentiment_distribution(df: pd.DataFrame) -> None:
"""
Plot a bar chart of sentiment label counts and a KDE of VADER compound scores.
Parameters
----------
df : DataFrame with 'sentiment_label' and 'vader_compound' columns
"""
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Left: bar chart of label counts
label_counts = df['sentiment_label'].value_counts().reindex(['POSITIVE', 'NEUTRAL', 'NEGATIVE'])
colors = ['#2ecc71', '#95a5a6', '#e74c3c']
bars = axes[0].bar(label_counts.index, label_counts.values, color=colors, width=0.5, edgecolor='white')
axes[0].set_title('Sentiment Label Distribution', fontsize=13, fontweight='bold')
axes[0].set_ylabel('Number of Posts')
for bar, val in zip(bars, label_counts.values):
axes[0].text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 1,
str(val), ha='center', va='bottom', fontsize=11)
# Right: KDE of VADER compound score
axes[1].axvline(x=0, color='gray', linestyle='--', linewidth=1, alpha=0.7)
axes[1].axvspan(-1, -0.05, alpha=0.08, color='red', label='Bearish zone')
axes[1].axvspan(0.05, 1, alpha=0.08, color='green', label='Bullish zone')
df['vader_compound'].plot.kde(ax=axes[1], color='steelblue', linewidth=2)
axes[1].set_title('VADER Compound Score Distribution', fontsize=13, fontweight='bold')
axes[1].set_xlabel('Compound Score (−1 = very negative, +1 = very positive)')
axes[1].set_xlim(-1, 1)
axes[1].legend()
plt.suptitle('Overall Crypto Reddit Sentiment', fontsize=15, y=1.02)
plt.tight_layout()
plt.show()
plot_sentiment_distribution(sentiment_df)def plot_sentiment_by_subreddit(df: pd.DataFrame) -> None:
"""
Stacked bar chart showing sentiment breakdown per subreddit.
Parameters
----------
df : DataFrame with 'subreddit', 'sentiment_label', and 'vader_compound' columns
"""
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
# Left: stacked bar of sentiment labels
pivot = df.groupby(['subreddit', 'sentiment_label']).size().unstack(fill_value=0)
for col in ['POSITIVE', 'NEUTRAL', 'NEGATIVE']:
if col not in pivot.columns:
pivot[col] = 0
pivot = pivot[['POSITIVE', 'NEUTRAL', 'NEGATIVE']]
pivot_pct = pivot.div(pivot.sum(axis=1), axis=0) * 100
pivot_pct.plot(kind='bar', stacked=True, ax=axes[0],
color=['#2ecc71', '#95a5a6', '#e74c3c'], edgecolor='white')
axes[0].set_title('Sentiment Breakdown by Subreddit (%)', fontsize=12, fontweight='bold')
axes[0].set_ylabel('Percentage of Posts')
axes[0].set_xlabel('')
axes[0].tick_params(axis='x', rotation=30)
axes[0].legend(loc='upper right')
# Right: mean compound score per subreddit
mean_compound = df.groupby('subreddit')['vader_compound'].mean().sort_values(ascending=True)
bar_colors = ['#e74c3c' if v < 0 else '#2ecc71' for v in mean_compound.values]
mean_compound.plot(kind='barh', ax=axes[1], color=bar_colors, edgecolor='white')
axes[1].axvline(x=0, color='black', linewidth=0.8)
axes[1].set_title('Mean VADER Compound Score by Subreddit', fontsize=12, fontweight='bold')
axes[1].set_xlabel('Mean Compound Score')
plt.tight_layout()
plt.show()
plot_sentiment_by_subreddit(sentiment_df)def generate_sentiment_wordclouds(df: pd.DataFrame, stop_words: set = None) -> None:
"""
Generate side-by-side word clouds for positive and negative posts.
Word size reflects frequency — larger words appear more often in posts
with that sentiment label.
Parameters
----------
df : DataFrame with 'sentiment_label' and 'combined_text' columns
stop_words : Optional set of words to exclude from clouds
"""
if stop_words is None:
stop_words = WORDCLOUD_STOP_WORDS
positive_text = ' '.join(df[df['sentiment_label'] == 'POSITIVE']['combined_text'].tolist())
negative_text = ' '.join(df[df['sentiment_label'] == 'NEGATIVE']['combined_text'].tolist())
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
for ax, text, title, bg, colormap in [
(axes[0], positive_text, 'Positive Posts — Top Keywords', '#f0fff0', 'Greens'),
(axes[1], negative_text, 'Negative Posts — Top Keywords', '#fff0f0', 'Reds')
]:
if text.strip():
wc = WordCloud(
width=700, height=400,
background_color=bg,
colormap=colormap,
stopwords=stop_words,
max_words=80,
collocations=False
).generate(text)
ax.imshow(wc, interpolation='bilinear')
else:
ax.text(0.5, 0.5, 'No data', ha='center', va='center', transform=ax.transAxes)
ax.set_title(title, fontsize=12, fontweight='bold')
ax.axis('off')
plt.suptitle('Most Frequent Words by Sentiment', fontsize=14)
plt.tight_layout()
plt.show()
generate_sentiment_wordclouds(sentiment_df)Section 7 — Aggregate Sentiment Signals
Individual post sentiment is noisy. For trading applications, we aggregate into a daily sentiment score — a single number per day that captures the net mood of all crypto Reddit posts.
The weighted compound score uses post score (net upvotes) as a weight, so highly-voted posts influence the daily signal more than low-engagement posts. This filters out fringe opinions.
daily_signal = Σ(vader_compound × post_score) / Σ(post_score)
A daily signal > 0 means Reddit was net bullish. This can be used as a feature in a trading model.
def compute_daily_sentiment(
df: pd.DataFrame,
date_col: str = 'created_utc',
score_col: str = 'vader_compound',
weight_col: str = 'score'
) -> pd.DataFrame:
"""
Aggregate post-level sentiment into a daily weighted sentiment time series.
Parameters
----------
df : DataFrame with sentiment scores and engagement data
date_col : Column containing datetime values
score_col : Sentiment column to aggregate (default: 'vader_compound')
weight_col : Engagement column for weighting (default: 'score')
Returns
-------
Daily DataFrame with columns: date, mean_sentiment, weighted_sentiment,
post_count, total_engagement
"""
df = df.copy()
df['date'] = pd.to_datetime(df[date_col]).dt.date
# Clip negative scores to 1 to avoid negative weights distorting the average
df['_weight'] = df[weight_col].clip(lower=1)
daily = df.groupby('date').apply(
lambda g: pd.Series({
'mean_sentiment': g[score_col].mean(),
'weighted_sentiment': (g[score_col] * g['_weight']).sum() / g['_weight'].sum(),
'post_count': len(g),
'total_engagement': g[weight_col].sum(),
'pct_positive': (g['sentiment_label'] == 'POSITIVE').mean() * 100,
'pct_negative': (g['sentiment_label'] == 'NEGATIVE').mean() * 100
})
).reset_index()
daily['date'] = pd.to_datetime(daily['date'])
daily.sort_values('date', inplace=True)
return daily
daily_df = compute_daily_sentiment(sentiment_df)
print(f'Daily sentiment data: {len(daily_df)} days')
daily_df.tail(5)Daily sentiment data: 8 days
/tmp/ipykernel_9041/3465677080.py:28: DeprecationWarning: DataFrameGroupBy.apply operated on the grouping columns. This behavior is deprecated, and in a future version of pandas the grouping columns will be excluded from the operation. Either pass `include_groups=False` to exclude the groupings or explicitly select the grouping columns after groupby to silence this warning.
daily = df.groupby('date').apply(
| date | mean_sentiment | weighted_sentiment | post_count | total_engagement | pct_positive | pct_negative | |
|---|---|---|---|---|---|---|---|
| 3 | 2026-06-12 | 0.098137 | 0.063351 | 59.0 | 53603.0 | 59.322034 | 40.677966 |
| 4 | 2026-06-13 | 0.248442 | 0.264187 | 71.0 | 76593.0 | 69.014085 | 30.985915 |
| 5 | 2026-06-14 | -0.133589 | -0.059627 | 56.0 | 63174.0 | 44.642857 | 55.357143 |
| 6 | 2026-06-15 | 0.052641 | -0.013319 | 64.0 | 59118.0 | 54.687500 | 45.312500 |
| 7 | 2026-06-16 | -0.061804 | 0.007509 | 53.0 | 50926.0 | 49.056604 | 50.943396 |
def plot_daily_sentiment_trend(daily_df: pd.DataFrame) -> None:
"""
Plot the daily weighted sentiment signal with post volume overlay.
Parameters
----------
daily_df : Output from compute_daily_sentiment()
"""
if len(daily_df) < 2:
print('Not enough days for a trend chart. Try scraping more posts with SORT_METHOD="new".')
return
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 7), sharex=True)
# Top: sentiment line
ax1.axhline(y=0, color='gray', linestyle='--', linewidth=1, alpha=0.6)
ax1.fill_between(daily_df['date'], daily_df['weighted_sentiment'], 0,
where=daily_df['weighted_sentiment'] >= 0,
alpha=0.3, color='#2ecc71', label='Bullish')
ax1.fill_between(daily_df['date'], daily_df['weighted_sentiment'], 0,
where=daily_df['weighted_sentiment'] < 0,
alpha=0.3, color='#e74c3c', label='Bearish')
ax1.plot(daily_df['date'], daily_df['weighted_sentiment'],
color='steelblue', linewidth=2, label='Weighted Sentiment')
ax1.set_ylabel('Weighted VADER Compound')
ax1.set_title('Daily Crypto Reddit Sentiment Signal', fontsize=13, fontweight='bold')
ax1.legend(loc='upper left')
ax1.set_ylim(-1, 1)
# Bottom: post volume
ax2.bar(daily_df['date'], daily_df['post_count'], color='steelblue', alpha=0.6, width=0.8)
ax2.set_ylabel('Number of Posts')
ax2.set_xlabel('Date')
ax2.set_title('Daily Post Volume', fontsize=11)
ax2.xaxis.set_major_formatter(mdates.DateFormatter('%b %d'))
plt.xticks(rotation=30)
plt.tight_layout()
plt.show()
plot_daily_sentiment_trend(daily_df)Section 8 — Export Results
Saving results to CSV lets you:
- Load data into a backtesting framework
- Join with OHLCV price data on the
datecolumn - Track sentiment drift over multiple scraping runs
Two files are saved:
reddit_crypto_posts_sentiment.csv— full post-level data with all scoresreddit_crypto_daily_signal.csv— aggregated daily signal ready for strategy use
def export_results(
posts_df: pd.DataFrame,
daily_df: pd.DataFrame,
posts_filename: str = 'reddit_crypto_posts_sentiment.csv',
daily_filename: str = 'reddit_crypto_daily_signal.csv'
) -> None:
"""
Export post-level and daily-aggregated DataFrames to CSV files.
Files are saved in the current working directory (Colab: /content/).
Download them via the Files panel (folder icon in the left sidebar).
Parameters
----------
posts_df : Full post DataFrame with sentiment scores
daily_df : Daily aggregated sentiment DataFrame
posts_filename : Output filename for post-level data
daily_filename : Output filename for daily signal
"""
# Post-level export — keep key columns
post_cols = [
'post_id', 'subreddit', 'created_utc', 'title', 'score',
'upvote_ratio', 'num_comments', 'vader_compound', 'vader_pos',
'vader_neg', 'tb_polarity', 'tb_subjectivity', 'combined_polarity',
'sentiment_label', 'text_length', 'url'
]
posts_df[post_cols].to_csv(posts_filename, index=False)
print(f'Post-level data saved: {posts_filename} ({len(posts_df)} rows)')
# Daily signal export
daily_df.to_csv(daily_filename, index=False)
print(f'Daily signal saved: {daily_filename} ({len(daily_df)} rows)')
export_results(sentiment_df, daily_df)Post-level data saved: reddit_crypto_posts_sentiment.csv (500 rows) Daily signal saved: reddit_crypto_daily_signal.csv (8 rows)
Section 9 — Summary & Next Steps
What We Built
| Step | Function | Output |
|---|---|---|
| Auth | create_reddit_client() | Authenticated PRAW client |
| Scrape | scrape_multiple_subreddits() | Raw post DataFrame |
| Clean | preprocess_dataframe() | Cleaned text columns |
| Score | run_sentiment_analysis() | VADER + TextBlob scores |
| Aggregate | compute_daily_sentiment() | Daily weighted signal |
| Export | export_results() | CSV files |
Extending This Notebook
1. Add comment scraping — post titles are brief; top comments often reveal deeper sentiment:
submission = reddit.submission(id=post_id)
submission.comments.replace_more(limit=0)
comments = [c.body for c in submission.comments.list()]
2. Entity-level sentiment — detect which coin is being discussed before scoring:
coin_mentions = {'BTC': ['bitcoin', 'btc'], 'ETH': ['ethereum', 'eth']}
3. Combine with price data — join daily_df with OHLCV data on date to build a correlation matrix:
merged = daily_df.merge(price_df, on='date')
merged[['weighted_sentiment', 'close']].corr()
4. Schedule automated runs — use Colab's built-in scheduler or a cron job to refresh data daily and append to the CSV.