Sentiment & NLP·NLP Models·Intermediate

Finbert Crypto Sentiment

Apply FinBERT, a financial domain-adapted BERT variant pre-trained on corporate filings and financial news, to cryptocurrency-related text for sentiment scoring that inherently understands financial language, market terminology, and numeric context in trading discussions.

nlpsentimentsentiment-analysis

FinBERT Crypto Sentiment Analysis — Sentiment & NLP

Category: Sentiment & NLP | Subcategory: NLP


What This Notebook Does

FinBERT is BERT fine-tuned on financial news, earnings call transcripts, and analyst reports. It understands domain-specific language: "the asset underperformed" (negative), "beat consensus estimates" (positive), "rate hike uncertainty" (negative) — phrases that general-purpose BERT often misclassifies.

This notebook:

  1. Loads ProsusAI's FinBERT and a crypto-specific variant from HuggingFace
  2. Processes financial news headlines, on-chain reports, and earnings-style crypto announcements
  3. Builds a news sentiment pipeline with confidence thresholds and filtering
  4. Compares FinBERT vs general BERT vs VADER on financial text
  5. Aggregates daily news sentiment scores weighted by source credibility
  6. Exports a signal ready for merging with OHLCV data

Why FinBERT for Crypto?

Crypto news increasingly uses financial language — funding rounds, protocol revenues, treasury management, institutional flows. FinBERT is trained specifically on this register. For crypto exchange announcements, project reports, and macro news affecting crypto, FinBERT outperforms Twitter-RoBERTa.

FinBERT Models Available

ModelTraining DataBest For
ProsusAI/finbertFinancial PhraseBankNews headlines, analyst reports
yiyanghkust/finbert-toneFinancial communicationsEarnings calls, official announcements
ahmedrachid/FinancialBERT-Sentiment-AnalysisFinancial news + socialMixed crypto/finance text
[4]
!pip install transformers vaderSentiment torch pandas matplotlib seaborn tqdm requests feedparser --quiet
[5]
import torch
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from transformers import BertTokenizer, BertForSequenceClassification, pipeline
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
from tqdm.auto import tqdm
import requests
import feedparser
import re
import time
from datetime import datetime, timezone
from dateutil import parser as dateparser

%matplotlib inline
plt.rcParams['figure.figsize'] = (12, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False

DEVICE = 0 if torch.cuda.is_available() else -1
print(f'Device: {"GPU — " + torch.cuda.get_device_name(0) if DEVICE == 0 else "CPU"}')
Device: CPU

Section 2 — Loading FinBERT

We load FinBERT two ways:

  1. Via pipeline — the fast, easy approach for inference
  2. Direct model + tokenizer — gives access to raw logits and hidden states (needed for fine-tuning)

FinBERT's three labels are: positive, neutral, negative. The confidence score (softmax probability) tells us how certain the model is — low-confidence predictions (<60%) should be treated as unreliable.

[6]
FINBERT_MODEL = 'ProsusAI/finbert'
BATCH_SIZE    = 16
MAX_LENGTH    = 128
CONFIDENCE_THRESHOLD = 0.60   # Minimum confidence to trust a prediction


def load_finbert_pipeline(model_name: str, device: int = -1):
    """
    Load FinBERT as a HuggingFace text-classification pipeline.

    Parameters
    ----------
    model_name : HuggingFace model ID (default: ProsusAI/finbert)
    device     : 0 for GPU, -1 for CPU

    Returns
    -------
    HuggingFace pipeline object
    """
    print(f'Loading {model_name}...')
    t0 = time.time()
    nlp = pipeline(
        'text-classification',
        model=model_name,
        tokenizer=model_name,
        device=device,
        truncation=True,
        max_length=MAX_LENGTH,
        return_all_scores=True
    )
    print(f'Loaded in {time.time() - t0:.1f}s')
    return nlp


finbert = load_finbert_pipeline(FINBERT_MODEL, DEVICE)
Loading ProsusAI/finbert...
/usr/local/lib/python3.12/dist-packages/huggingface_hub/utils/_auth.py:112: UserWarning: 
The secret `HF_TOKEN` does not exist in your Colab secrets.
To authenticate with the Hugging Face Hub, create a token in your settings tab (https://huggingface.co/settings/tokens), set it as secret in your Google Colab and restart your session.
You will be able to reuse this secret in all of your notebooks.
Please note that authentication is recommended but still optional to access public models or datasets.
  warnings.warn(
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
WARNING:huggingface_hub.utils._http:Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
config.json:   0%|          | 0.00/758 [00:00<?, ?B/s]
pytorch_model.bin:   0%|          | 0.00/438M [00:00<?, ?B/s]
model.safetensors:   0%|          | 0.00/438M [00:00<?, ?B/s]
Loading weights:   0%|          | 0/201 [00:00<?, ?it/s]
tokenizer_config.json:   0%|          | 0.00/252 [00:00<?, ?B/s]
vocab.txt:   0%|          | 0.00/232k [00:00<?, ?B/s]
special_tokens_map.json:   0%|          | 0.00/112 [00:00<?, ?B/s]
Loaded in 12.1s

Section 3 — Fetching Crypto News via RSS

We fetch real crypto news headlines from public RSS feeds. No API key required. RSS gives us fresh headlines in structured XML format that feedparser parses automatically.

Sources used:

  • CoinDesk
  • Cointelegraph
  • Decrypt
  • Bitcoin Magazine
[7]
CRYPTO_RSS_FEEDS = {
    'CoinDesk':       'https://www.coindesk.com/arc/outboundfeeds/rss/',
    'Cointelegraph':  'https://cointelegraph.com/rss',
    'Decrypt':        'https://decrypt.co/feed',
    'BitcoinMagazine': 'https://bitcoinmagazine.com/.rss/full/'
}


def fetch_rss_headlines(feeds: dict, max_per_source: int = 50) -> pd.DataFrame:
    """
    Fetch recent crypto news headlines from RSS feeds.

    Parameters
    ----------
    feeds           : Dictionary of {source_name: rss_url}
    max_per_source  : Maximum articles to fetch per source

    Returns
    -------
    DataFrame with columns: source, title, summary, published, link
    """
    all_articles = []
    print('Fetching RSS feeds...')

    for source, url in feeds.items():
        try:
            feed = feedparser.parse(url)
            count = 0
            for entry in feed.entries[:max_per_source]:
                published = None
                if hasattr(entry, 'published'):
                    try:
                        published = dateparser.parse(entry.published)
                    except Exception:
                        pass

                all_articles.append({
                    'source':    source,
                    'title':     entry.get('title', ''),
                    'summary':   re.sub('<[^>]+>', '', entry.get('summary', ''))[:300],
                    'published': published,
                    'link':      entry.get('link', '')
                })
                count += 1
            print(f'  {source}: {count} articles')
        except Exception as e:
            print(f'  WARNING: {source} failed — {e}')

    df = pd.DataFrame(all_articles)
    df.dropna(subset=['title'], inplace=True)
    df = df[df['title'].str.len() > 10].reset_index(drop=True)
    print(f'\nTotal articles: {len(df)}')
    return df


news_df = fetch_rss_headlines(CRYPTO_RSS_FEEDS)
news_df[['source', 'title', 'published']].head(5)
Fetching RSS feeds...
  CoinDesk: 25 articles
  Cointelegraph: 30 articles
  Decrypt: 35 articles
  BitcoinMagazine: 10 articles

Total articles: 100
source title published
0 CoinDesk XRP jumps 3% above $1.14 as institutional buyi... 2026-06-12 06:38:32+00:00
1 CoinDesk Live updates: Bitcoin in volatile trading abov... 2026-06-12 06:32:02+00:00
2 CoinDesk Former SEC, CFTC Chair Gary Gensler argues tha... 2026-06-12 06:27:20+00:00
3 CoinDesk SpaceX's crypto-traded IPO was sharply falling... 2026-06-12 06:13:44+00:00
4 CoinDesk The company that makes your TV is taking ads o... 2026-06-12 05:35:25+00:00

Section 4 — FinBERT Batch Inference

Financial news headlines are concise — usually under 128 tokens. This means FinBERT's fixed context window rarely truncates them, giving reliable full-text understanding.

We score both the headline and summary separately, then average them into a composite score. Headlines tend to be more emotionally charged; summaries add factual context.

[11]
def run_finbert_inference(
    texts: list[str],
    nlp_pipeline,
    batch_size: int = 16,
    confidence_threshold: float = 0.0
) -> pd.DataFrame:
    """
    Run FinBERT inference on a list of texts.
    """
    cleaned = [re.sub(r'\s+', ' ', str(t)).strip()[:512] for t in texts]

    all_results = []
    for i in tqdm(range(0, len(cleaned), batch_size), desc='FinBERT'):
        batch = cleaned[i: i + batch_size]
        outputs = nlp_pipeline(batch, batch_size=len(batch))
        all_results.extend(outputs)

    rows = []
    for result in all_results:
        if isinstance(result, dict):
            result = [result]

        score_map = {item['label'].lower(): item['score'] for item in result}
        top = max(result, key=lambda x: x['score'])
        label_raw = top['label'].lower()
        confidence = top['score']
        final_label = label_raw if confidence >= confidence_threshold else 'uncertain'

        rows.append({
            'finbert_positive':   score_map.get('positive', 0.0),
            'finbert_neutral':    score_map.get('neutral', 0.0),
            'finbert_negative':   score_map.get('negative', 0.0),
            'finbert_label':      final_label,
            'finbert_confidence': confidence,
            'finbert_score':      score_map.get('positive', 0.0) - score_map.get('negative', 0.0)
        })

    return pd.DataFrame(rows)

# Score titles and summaries
title_scores   = run_finbert_inference(news_df['title'].tolist(), finbert, BATCH_SIZE, CONFIDENCE_THRESHOLD)
summary_scores = run_finbert_inference(news_df['summary'].tolist(), finbert, BATCH_SIZE, CONFIDENCE_THRESHOLD)

# Rename summary columns - using prefix to avoid the KeyError mismatch
summary_scores = summary_scores.add_prefix('summary_')

# Merge data
news_scored = pd.concat([news_df, title_scores, summary_scores], axis=1)

# Composite score calculation using the correct prefixes
news_scored['composite_score'] = (news_scored['finbert_score'] + news_scored['summary_finbert_score']) / 2
news_scored['composite_label'] = news_scored['composite_score'].apply(
    lambda s: 'positive' if s >= 0.1 else ('negative' if s <= -0.1 else 'neutral')
)

print(f'Scored {len(news_scored)} articles')
news_scored[['source', 'title', 'finbert_label', 'finbert_confidence', 'composite_score']].head(6)
FinBERT:   0%|          | 0/7 [00:00<?, ?it/s]
FinBERT:   0%|          | 0/7 [00:00<?, ?it/s]
Scored 100 articles
source title finbert_label finbert_confidence composite_score
0 CoinDesk XRP jumps 3% above $1.14 as institutional buyi... positive 0.927841 0.463921
1 CoinDesk Live updates: Bitcoin in volatile trading abov... uncertain 0.592874 0.000000
2 CoinDesk Former SEC, CFTC Chair Gary Gensler argues tha... neutral 0.858123 0.000000
3 CoinDesk SpaceX's crypto-traded IPO was sharply falling... negative 0.968770 -0.484385
4 CoinDesk The company that makes your TV is taking ads o... neutral 0.945123 0.000000
5 CoinDesk Bitcoin climbs back into the green as Trump si... uncertain 0.560128 0.280064

Section 5 — FinBERT vs VADER vs General BERT

Financial news is where FinBERT's domain training pays off. "Interest rate hike concerns weigh on risk assets" — VADER sees neutral words and scores ~0, but FinBERT correctly identifies negative sentiment.

[12]
FINANCIAL_TEST_CASES = [
    'Bitcoin ETF inflows hit record $500M as institutional demand surges',
    'SEC delays decision on spot Ethereum ETF amid regulatory uncertainty',
    'Crypto exchange reports record quarterly revenue beating analyst estimates',
    'DeFi protocol suffers $50M exploit due to smart contract vulnerability',
    'Federal Reserve signals potential rate cuts boosting risk asset sentiment',
    'Stablecoin issuer faces liquidity concerns amid market stress conditions',
    'Layer 2 adoption accelerates as transaction costs fall below $0.01',
    'Venture capital funding in Web3 declined 68% year over year in Q3',
    'Bitcoin mining difficulty reached all-time high following hash rate surge',
    'Regulatory crackdown forces crypto firms to exit several emerging markets'
]


def compare_models_on_financial_text(texts: list[str], finbert_pipeline) -> pd.DataFrame:
    """
    Score financial text snippets with FinBERT and VADER for comparison.

    Parameters
    ----------
    texts            : List of financial text strings
    finbert_pipeline : Loaded FinBERT pipeline

    Returns
    -------
    Comparison DataFrame
    """
    vader_analyzer = SentimentIntensityAnalyzer()

    finbert_scores = run_finbert_inference(texts, finbert_pipeline, batch_size=8)

    rows = []
    for i, text in enumerate(texts):
        vc = vader_analyzer.polarity_scores(text)['compound']
        rows.append({
            'text':              text[:80] + '...' if len(text) > 80 else text,
            'finbert_label':     finbert_scores.iloc[i]['finbert_label'],
            'finbert_score':     round(finbert_scores.iloc[i]['finbert_score'], 3),
            'vader_label':       'positive' if vc >= 0.05 else ('negative' if vc <= -0.05 else 'neutral'),
            'vader_compound':    round(vc, 3),
            'models_agree':      finbert_scores.iloc[i]['finbert_label'] == ('positive' if vc >= 0.05 else ('negative' if vc <= -0.05 else 'neutral'))
        })

    df = pd.DataFrame(rows)
    agree_pct = df['models_agree'].mean() * 100
    print(f'Agreement rate: {agree_pct:.1f}%')
    return df


comparison_df = compare_models_on_financial_text(FINANCIAL_TEST_CASES, finbert)
comparison_df.style.applymap(
    lambda v: 'background-color: #d4edda' if v == 'positive'
    else ('background-color: #f8d7da' if v == 'negative' else ''),
    subset=['finbert_label', 'vader_label']
)
FinBERT:   0%|          | 0/2 [00:00<?, ?it/s]
Agreement rate: 40.0%
/tmp/ipykernel_1480/2291582079.py:51: FutureWarning: Styler.applymap has been deprecated. Use Styler.map instead.
  comparison_df.style.applymap(
  text finbert_label finbert_score vader_label vader_compound models_agree
0 Bitcoin ETF inflows hit record $500M as institutional demand surges positive 0.936000 negative -0.128000 False
1 SEC delays decision on spot Ethereum ETF amid regulatory uncertainty negative -0.876000 negative -0.340000 True
2 Crypto exchange reports record quarterly revenue beating analyst estimates positive 0.935000 negative -0.459000 False
3 DeFi protocol suffers $50M exploit due to smart contract vulnerability negative -0.972000 negative -0.402000 True
4 Federal Reserve signals potential rate cuts boosting risk asset sentiment positive 0.936000 positive 0.153000 True
5 Stablecoin issuer faces liquidity concerns amid market stress conditions negative -0.953000 negative -0.421000 True
6 Layer 2 adoption accelerates as transaction costs fall below $0.01 negative -0.527000 neutral 0.000000 False
7 Venture capital funding in Web3 declined 68% year over year in Q3 negative -0.975000 neutral 0.000000 False
8 Bitcoin mining difficulty reached all-time high following hash rate surge positive 0.881000 negative -0.250000 False
9 Regulatory crackdown forces crypto firms to exit several emerging markets negative -0.957000 neutral 0.000000 False

Section 6 — Daily News Sentiment Signal

Aggregate article-level scores into a daily signal. Each source is weighted by a credibility multiplier — established outlets like CoinDesk carry more weight than aggregator blogs.

[13]
SOURCE_CREDIBILITY = {
    'CoinDesk':        1.5,
    'Cointelegraph':   1.3,
    'Decrypt':         1.2,
    'BitcoinMagazine': 1.1
}


def build_daily_news_signal(df: pd.DataFrame, credibility_map: dict) -> pd.DataFrame:
    """
    Aggregate article-level FinBERT scores into a daily sentiment signal.

    Each article is weighted by source credibility. The resulting
    daily score reflects the credibility-weighted news sentiment.

    Parameters
    ----------
    df               : Scored news DataFrame with 'published' and 'composite_score'
    credibility_map  : Dictionary of {source_name: weight_multiplier}

    Returns
    -------
    Daily signal DataFrame
    """
    df = df.copy()
    df['published'] = pd.to_datetime(df['published'], utc=True, errors='coerce')
    df.dropna(subset=['published'], inplace=True)
    df['date']   = df['published'].dt.date
    df['weight'] = df['source'].map(credibility_map).fillna(1.0)

    daily = df.groupby('date').apply(
        lambda g: pd.Series({
            'weighted_sentiment': (g['composite_score'] * g['weight']).sum() / g['weight'].sum(),
            'mean_sentiment':     g['composite_score'].mean(),
            'article_count':      len(g),
            'pct_positive':       (g['composite_label'] == 'positive').mean() * 100,
            'pct_negative':       (g['composite_label'] == 'negative').mean() * 100,
            'sources':            ', '.join(g['source'].unique())
        })
    ).reset_index()

    daily['date'] = pd.to_datetime(daily['date'])
    daily.sort_values('date', inplace=True)
    return daily


daily_news = build_daily_news_signal(news_scored, SOURCE_CREDIBILITY)
daily_news.tail(5)
/tmp/ipykernel_1480/1659870740.py:31: 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 weighted_sentiment mean_sentiment article_count pct_positive pct_negative sources
0 2026-06-09 0.000000 0.000000 1 0.000000 0.000000 BitcoinMagazine
1 2026-06-10 -0.221593 -0.216805 26 7.692308 34.615385 Cointelegraph, Decrypt, BitcoinMagazine
2 2026-06-11 0.003628 -0.003154 64 29.687500 29.687500 CoinDesk, Cointelegraph, Decrypt, BitcoinMagazine
3 2026-06-12 -0.061033 -0.071731 9 22.222222 22.222222 CoinDesk, Cointelegraph

Section 7 — Visualization

[14]
def plot_finbert_news_results(news_df: pd.DataFrame, daily_df: pd.DataFrame) -> None:
    fig, axes = plt.subplots(2, 2, figsize=(14, 9))

    # Top-left: label distribution
    counts = news_df['finbert_label'].value_counts()
    axes[0, 0].bar(counts.index, counts.values,
                   color=['#2ecc71' if l == 'positive' else '#e74c3c' if l == 'negative' else '#95a5a6' for l in counts.index],
                   edgecolor='white')
    axes[0, 0].set_title('FinBERT Label Distribution', fontweight='bold')
    axes[0, 0].set_ylabel('Article Count')

    # Top-right: confidence distribution
    news_df['finbert_confidence'].plot.hist(bins=20, ax=axes[0, 1], color='steelblue', edgecolor='white')
    axes[0, 1].axvline(0.6, color='red', linestyle='--', linewidth=1.5, label='Confidence threshold (0.6)')
    axes[0, 1].set_title('FinBERT Confidence Distribution', fontweight='bold')
    axes[0, 1].set_xlabel('Confidence Score')
    axes[0, 1].legend()

    # Bottom-left: sentiment by source
    by_source = news_df.groupby('source')['composite_score'].mean().sort_values()
    bar_colors = ['#e74c3c' if v < 0 else '#2ecc71' for v in by_source.values]
    by_source.plot(kind='barh', ax=axes[1, 0], color=bar_colors, edgecolor='white')
    axes[1, 0].axvline(0, color='black', linewidth=0.8)
    axes[1, 0].set_title('Avg Sentiment Score by Source', fontweight='bold')

    # Bottom-right: daily signal
    if len(daily_df) > 1:
        axes[1, 1].fill_between(daily_df['date'], daily_df['weighted_sentiment'], 0,
                                where=daily_df['weighted_sentiment'] >= 0, alpha=0.3, color='green')
        axes[1, 1].fill_between(daily_df['date'], daily_df['weighted_sentiment'], 0,
                                where=daily_df['weighted_sentiment'] < 0,  alpha=0.3, color='red')
        axes[1, 1].plot(daily_df['date'], daily_df['weighted_sentiment'], color='steelblue', linewidth=2)
        axes[1, 1].axhline(0, color='gray', linestyle='--', linewidth=0.8)
        axes[1, 1].set_title('Daily Weighted News Sentiment', fontweight='bold')
        plt.setp(axes[1, 1].xaxis.get_majorticklabels(), rotation=30)

    plt.suptitle('FinBERT Crypto News Sentiment Analysis', fontsize=14)
    plt.tight_layout()
    plt.show()


plot_finbert_news_results(news_scored, daily_news)
cell output

Section 8 — Export & Summary

[15]
news_cols = ['source', 'title', 'published', 'finbert_label', 'finbert_score',
             'finbert_confidence', 'composite_score', 'composite_label', 'link']
news_scored[news_cols].to_csv('finbert_news_sentiment.csv', index=False)
daily_news.to_csv('finbert_daily_signal.csv', index=False)
print(f'Exported: {len(news_scored)} articles, {len(daily_news)} daily rows')
print('\nTop 5 most positive headlines:')
print(news_scored.nlargest(5, 'composite_score')[['source', 'title', 'composite_score']].to_string(index=False))
print('\nTop 5 most negative headlines:')
print(news_scored.nsmallest(5, 'composite_score')[['source', 'title', 'composite_score']].to_string(index=False))
Exported: 100 articles, 4 daily rows

Top 5 most positive headlines:
         source                                                                                                  title  composite_score
  Cointelegraph                      Franklin Templeton, BNP Paribas see tokenization boosting EU's capital efficiency         0.947196
  Cointelegraph                                                     MassPay taps Coinbase to expand stablecoin payouts         0.931285
BitcoinMagazine Nakamoto Inc. (NAKA) Strengthens Balance With 600 Bitcoin Sale, Refinancing, and Buyback Authorization         0.877840
  Cointelegraph                                   Coinbase eyes World Cup boost as prediction markets surge: Bernstein         0.838539
  Cointelegraph                         Bitcoin tags $63.2K as BTC price action ignores inflation, Iran Hormuz closure         0.791705

Top 5 most negative headlines:
       source                                                                                        title  composite_score
Cointelegraph                         XRP transaction demand falls 91.5% as traders focus on $0.65 support        -0.962202
Cointelegraph                        Bitcoin miner margins fall to record low: Will BTC’s $60K floor hold?        -0.958160
Cointelegraph                                    Three signs that XRP price risks falling below $1 in June        -0.948056
      Decrypt Whistleblower Sues Elon Musk's xAI, Claiming He Was Fired After Raising Grok Safety Concerns        -0.939409
Cointelegraph                                      Avalanche Treasury Co. falls 16% as it debuts on Nasdaq        -0.905175

Section 9 — Summary

Why FinBERT over General BERT for Crypto News

  • Understands financial jargon: "liquidity crunch", "TVL decline", "funding rate compression"
  • Domain pre-training reduces false neutrals on technical financial statements
  • Confidence scores allow filtering unreliable predictions

Production Pipeline

RSS Fetch (hourly) → FinBERT Score → Confidence Filter → Daily Signal → Strategy Feature