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.
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:
- Loads ProsusAI's FinBERT and a crypto-specific variant from HuggingFace
- Processes financial news headlines, on-chain reports, and earnings-style crypto announcements
- Builds a news sentiment pipeline with confidence thresholds and filtering
- Compares FinBERT vs general BERT vs VADER on financial text
- Aggregates daily news sentiment scores weighted by source credibility
- 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
| Model | Training Data | Best For |
|---|---|---|
ProsusAI/finbert | Financial PhraseBank | News headlines, analyst reports |
yiyanghkust/finbert-tone | Financial communications | Earnings calls, official announcements |
ahmedrachid/FinancialBERT-Sentiment-Analysis | Financial news + social | Mixed crypto/finance text |
!pip install transformers vaderSentiment torch pandas matplotlib seaborn tqdm requests feedparser --quietimport 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:
- Via pipeline — the fast, easy approach for inference
- 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.
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
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.
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.
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.
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
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)Section 8 — Export & Summary
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