Sentiment & NLP·Social Media Scraping·Beginner

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.

data-fetchingsentiment-analysis

Reddit Crypto Scraper — Sentiment & NLP

Category: Sentiment & NLP | Subcategory: Social


What This Notebook Does

This notebook builds a complete pipeline to:

  1. Scrape posts and comments from crypto-focused Reddit communities using the PRAW library
  2. Preprocess raw text — clean noise, tokenize, and normalize
  3. Analyze sentiment using VADER (rule-based) and TextBlob (ML-based)
  4. Visualize sentiment distributions, trends over time, and keyword clouds
  5. 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

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 text
  • textblob — simple ML-based polarity and subjectivity scorer
  • wordcloud — for visual keyword analysis
  • Standard data science stack: pandas, matplotlib, seaborn
[3]
# Install required libraries (Colab resets on each session)
!pip install praw vaderSentiment textblob wordcloud --quiet
[?25l   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/199.2 kB ? eta -:--:--
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 199.2/199.2 kB 8.3 MB/s eta 0:00:00
[?25h[?25l   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/126.0 kB ? eta -:--:--
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 126.0/126.0 kB 8.9 MB/s eta 0:00:00
[?25h[?25l   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 0.0/73.1 kB ? eta -:--:--
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 73.1/73.1 kB 4.5 MB/s eta 0:00:00
[?25hERROR: 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.

[4]
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 key
  • user_agent — a descriptive string identifying your script

How to get credentials:

  1. Go to https://www.reddit.com/prefs/apps
  2. Click "create another app"
  3. Select script, fill in any name and redirect URI (http://localhost)
  4. Copy client_id (below the app name) and client_secret

Security Note: Never hard-code credentials in shared notebooks. Use Colab's Secrets manager (🔑 icon in the left sidebar) or environment variables.

[15]
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:

FieldDescription
titlePost headline — often the most sentiment-rich field
selftextPost body text
scoreNet upvotes (upvotes − downvotes)
upvote_ratioFraction of votes that are upvotes
num_commentsEngagement signal
created_utcUnix timestamp → converted to datetime
subredditSource community
[16]
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.

[17]
# 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 (GREAT scores higher than great), punctuation (!!!), and slang
  • Returns four scores: neg, neu, pos, and compound (−1 to +1)
  • Best for: Short, informal crypto posts

TextBlob

  • Pattern-based ML approach trained on movie reviews
  • Returns polarity (−1 to +1) and subjectivity (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
[8]
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:

  1. Exploration — quickly spot patterns in the data
  2. 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
[9]
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)
cell output
[10]
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)
cell output
[11]
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)
cell output

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.

[12]
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
[13]
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)
cell output

Section 8 — Export Results

Saving results to CSV lets you:

  • Load data into a backtesting framework
  • Join with OHLCV price data on the date column
  • Track sentiment drift over multiple scraping runs

Two files are saved:

  1. reddit_crypto_posts_sentiment.csv — full post-level data with all scores
  2. reddit_crypto_daily_signal.csv — aggregated daily signal ready for strategy use
[14]
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

StepFunctionOutput
Authcreate_reddit_client()Authenticated PRAW client
Scrapescrape_multiple_subreddits()Raw post DataFrame
Cleanpreprocess_dataframe()Cleaned text columns
Scorerun_sentiment_analysis()VADER + TextBlob scores
Aggregatecompute_daily_sentiment()Daily weighted signal
Exportexport_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.