Sentiment & NLP·NLP Models·Intermediate

LLM News Summarizer

Build a cryptocurrency news summarization pipeline using large language models with structured prompt engineering that extracts key market-moving information, named entities, sentiment signals, and event relationships from lengthy unstructured financial news articles.

nlpsentiment-analysis

LLM-Based Crypto News Summarizer — Sentiment & NLP

Category: Sentiment & NLP | Subcategory: NLP


What This Notebook Does

FinBERT classifies sentiment. But it doesn't tell you why a headline is bearish, which coins are affected, or what a trader should know. Large Language Models (LLMs) can do all of this in a single structured prompt.

This notebook:

  1. Fetches crypto news from RSS feeds and the CryptoPanic API
  2. Summarizes each article using an LLM (Anthropic Claude or OpenAI GPT)
  3. Extracts structured trading-relevant fields: sentiment, affected coins, event type, impact
  4. Builds a daily digest — a single summary of the day's most important crypto news
  5. Generates a trading signal from the structured LLM output
  6. Exports results in both CSV and markdown formats

LLM vs Classification Model

FinBERTLLM (Claude/GPT)
SpeedFast (batch)Slower (API calls)
OutputLabel + confidenceFull structured analysis
Context window512 tokens100k+ tokens
CostFree (local)API usage cost
Best forHigh-volume screeningDeep analysis of important events

Recommended Workflow

Use FinBERT to screen all news (fast, free), then pass the top N most extreme articles to an LLM for deep structured analysis.

[15]
!pip install anthropic openai feedparser pandas matplotlib requests tqdm groq --quiet
[16]
import anthropic
import openai
import groq
import feedparser
import pandas as pd
import matplotlib.pyplot as plt
import requests
import json
import re
import time
from datetime import datetime, timezone
from dateutil import parser as dateparser
from tqdm.auto import tqdm

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

print('Imports successful.')
Imports successful.

Section 2 — LLM Client Setup

We support Anthropic Claude, OpenAI GPT, and Groq models. The notebook defaults to Groq, but will use Claude or GPT if only their respective API keys are available.

Getting API keys:

Cost estimate: ~100 news headlines at ~200 tokens each = ~$0.02-0.05 per run depending on the model.

Free alternative: The notebook includes a section using a local HuggingFace summarization model (facebook/bart-large-cnn) that requires no API key.

[17]
# ─── CONFIGURATION ──────────────────────────────────────────────────────────
ANTHROPIC_API_KEY = 'YOUR_ANTHROPIC_API_KEY'  # or use Colab Secrets
OPENAI_API_KEY    = 'YOUR_OPENAI_API_KEY'
GROQ_API_KEY      = 'YOUR_GROQ_API_KEY'       # or use Colab Secrets

# Which LLM provider to use: 'anthropic', 'openai', or 'groq'
LLM_PROVIDER = 'groq'

# Model IDs
CLAUDE_MODEL = 'claude-haiku-4-5-20251001'  # Fastest + cheapest Claude model
GPT_MODEL    = 'gpt-4o-mini'
GROQ_MODEL   = 'llama-3.3-70b-versatile'             # A fast open-source model from Groq

# How many articles to send to the LLM (cost control)
MAX_ARTICLES_FOR_LLM = 20

# Rate limit: pause between API calls (seconds)
API_CALL_DELAY = 0.5
# ────────────────────────────────────────────────────────────────────────────

def create_llm_client(provider: str, anthropic_key: str, openai_key: str, groq_key: str):
    """
    Initialize and return an LLM API client.

    Parameters
    ----------
    provider     : 'anthropic', 'openai', or 'groq'
    anthropic_key: Anthropic API key
    openai_key   : OpenAI API key
    groq_key     : Groq API key

    Returns
    -------
    Tuple of (client, provider_name)
    """
    if provider == 'anthropic':
        client = anthropic.Anthropic(api_key=anthropic_key)
        print(f'Anthropic client ready. Model: {CLAUDE_MODEL}')
        return client, 'anthropic'
    elif provider == 'openai':
        client = openai.OpenAI(api_key=openai_key)
        print(f'OpenAI client ready. Model: {GPT_MODEL}')
        return client, 'openai'
    elif provider == 'groq':
        client = groq.Groq(api_key=groq_key)
        print(f'Groq client ready. Model: {GROQ_MODEL}')
        return client, 'groq'
    else:
        raise ValueError(f'Unknown provider: {provider}. Use "anthropic", "openai", or "groq".')


llm_client, llm_provider = create_llm_client(LLM_PROVIDER, ANTHROPIC_API_KEY, OPENAI_API_KEY, GROQ_API_KEY)
Groq client ready. Model: llama-3.3-70b-versatile

Section 3 — News Fetching

We use the same RSS approach as notebook 117, plus an optional CryptoPanic API integration for categorized crypto-specific news.

[18]
CRYPTO_RSS_FEEDS = {
    'CoinDesk':      'https://www.coindesk.com/arc/outboundfeeds/rss/',
    'Cointelegraph': 'https://cointelegraph.com/rss',
    'Decrypt':       'https://decrypt.co/feed'
}


def fetch_news_for_llm(feeds: dict, max_per_source: int = 15) -> pd.DataFrame:
    """
    Fetch news articles from RSS feeds, combining title and summary
    into a single text field for LLM analysis.

    Parameters
    ----------
    feeds          : Dictionary of {source_name: rss_url}
    max_per_source : Articles per source

    Returns
    -------
    DataFrame with 'full_text' column (title + summary)
    """
    articles = []
    for source, url in feeds.items():
        try:
            feed = feedparser.parse(url)
            for entry in feed.entries[:max_per_source]:
                title   = entry.get('title', '')
                summary = re.sub('<[^>]+>', '', entry.get('summary', ''))[:500]
                published = None
                if hasattr(entry, 'published'):
                    try:
                        published = dateparser.parse(entry.published)
                    except Exception:
                        pass
                articles.append({
                    'source':    source,
                    'title':     title,
                    'summary':   summary,
                    'full_text': f'{title}. {summary}'.strip(),
                    'published': published,
                    'link':      entry.get('link', '')
                })
        except Exception as e:
            print(f'WARNING: {source}{e}')

    df = pd.DataFrame(articles)
    df = df[df['full_text'].str.len() > 20].reset_index(drop=True)
    print(f'Fetched {len(df)} articles from {len(feeds)} sources')
    return df


news_df = fetch_news_for_llm(CRYPTO_RSS_FEEDS)
news_df[['source', 'title', 'published']].head(5)
Fetched 45 articles from 3 sources
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 — Structured LLM Extraction

The key to getting reliable, parseable output from an LLM is structured prompting. We ask the LLM to return JSON with specific fields. This lets us parse the response into a DataFrame without regex hacks.

Extraction Schema

{
  "sentiment": "bullish" | "bearish" | "neutral",
  "sentiment_score": -1.0 to 1.0,
  "affected_coins": ["BTC", "ETH", ...],
  "event_type": "regulatory" | "adoption" | "hack" | "macro" | "technical" | "earnings" | "other",
  "impact_level": "high" | "medium" | "low",
  "time_horizon": "intraday" | "swing" | "long-term",
  "one_line_summary": "Brief plain-English summary",
  "trading_implication": "What a trader should know"
}
[19]
EXTRACTION_SYSTEM_PROMPT = """You are a crypto trading analyst. Analyze news articles and return ONLY valid JSON with these exact fields:
{
  "sentiment": one of ["bullish", "bearish", "neutral"],
  "sentiment_score": float from -1.0 (very bearish) to 1.0 (very bullish),
  "affected_coins": list of ticker symbols mentioned (e.g. ["BTC", "ETH"]),
  "event_type": one of ["regulatory", "adoption", "hack", "macro", "technical", "earnings", "other"],
  "impact_level": one of ["high", "medium", "low"],
  "time_horizon": one of ["intraday", "swing", "long-term"],
  "one_line_summary": string (max 100 chars),
  "trading_implication": string (max 150 chars)
}
Return ONLY the JSON object. No explanation, no markdown, no extra text."""


def call_llm(
    client,
    provider: str,
    text: str,
    system_prompt: str
) -> str:
    """
    Call an LLM API (Anthropic, OpenAI, or Groq) and return the response text.

    Parameters
    ----------
    client        : LLM API client
    provider      : 'anthropic', 'openai', or 'groq'
    text          : User message (the news article text)
    system_prompt : System instruction for the model

    Returns
    -------
    Raw response string from the LLM
    """
    if provider == 'anthropic':
        response = client.messages.create(
            model=CLAUDE_MODEL,
            max_tokens=256,
            system=system_prompt,
            messages=[{'role': 'user', 'content': text}]
        )
        return response.content[0].text

    elif provider == 'openai':
        response = client.chat.completions.create(
            model=GPT_MODEL,
            max_tokens=256,
            messages=[
                {'role': 'system', 'content': system_prompt},
                {'role': 'user',   'content': text}
            ]
        )
        return response.choices[0].message.content

    elif provider == 'groq':
        response = client.chat.completions.create(
            model=GROQ_MODEL,
            max_tokens=256,
            messages=[
                {'role': 'system', 'content': system_prompt},
                {'role': 'user', 'content': text}
            ]
        )
        return response.choices[0].message.content


def parse_llm_json(raw_response: str) -> dict:
    """
    Parse JSON from an LLM response, handling common formatting issues.

    LLMs sometimes wrap JSON in markdown code blocks or add trailing text.
    This function extracts and parses the JSON robustly.

    Parameters
    ----------
    raw_response : Raw string from LLM API

    Returns
    -------
    Parsed dictionary, or empty dict on failure
    """
    json_match = re.search(r'\{.*\}', raw_response, re.DOTALL)
    if not json_match:
        return {}
    try:
        return json.loads(json_match.group(0))
    except json.JSONDecodeError:
        return {}


def extract_article_insights(
    df: pd.DataFrame,
    client,
    provider: str,
    max_articles: int = 20,
    delay: float = 0.5
) -> pd.DataFrame:
    """
    Run LLM structured extraction on news articles.

    Processes `max_articles` from the DataFrame, calls the LLM for each,
    and merges the structured output back into the DataFrame.

    Parameters
    ----------
    df           : News DataFrame with 'full_text' column
    client       : LLM API client
    provider     : 'anthropic', 'openai', or 'groq'
    max_articles : How many articles to process (cost limit)
    delay        : Seconds to wait between API calls

    Returns
    -------
    DataFrame with LLM-extracted fields added
    """
    subset = df.head(max_articles).copy()
    results = []

    for _, row in tqdm(subset.iterrows(), total=len(subset), desc='LLM extraction'):
        raw = call_llm(client, provider, row['full_text'][:800], EXTRACTION_SYSTEM_PROMPT)
        parsed = parse_llm_json(raw)
        results.append(parsed)
        time.sleep(delay)

    extracted = pd.DataFrame(results)
    result = pd.concat([subset.reset_index(drop=True), extracted], axis=1)
    print(f'Extracted insights for {len(result)} articles')
    return result


extracted_df = extract_article_insights(news_df, llm_client, llm_provider, MAX_ARTICLES_FOR_LLM, API_CALL_DELAY)
extracted_df[['title', 'sentiment', 'sentiment_score', 'affected_coins', 'event_type', 'impact_level']].head(6)
LLM extraction:   0%|          | 0/20 [00:00<?, ?it/s]
Extracted insights for 20 articles
title sentiment sentiment_score affected_coins event_type impact_level
0 XRP jumps 3% above $1.14 as institutional buyi... bullish 0.8 [XRP] adoption medium
1 Live updates: Bitcoin in volatile trading abov... neutral 0.0 [BTC, DOGE] other low
2 Former SEC, CFTC Chair Gary Gensler argues tha... neutral 0.0 [] regulatory medium
3 SpaceX's crypto-traded IPO was sharply falling... bullish 0.8 [] other high
4 The company that makes your TV is taking ads o... bullish 0.8 [ARB] adoption medium
5 Bitcoin climbs back into the green as Trump si... bullish 0.6 [BTC] macro medium

Section 5 — Daily Digest Generation

Beyond article-level analysis, we build a daily digest — a concise summary of all articles that day, written for a crypto trader. This uses a second LLM call with a different prompt that receives multiple article summaries at once.

[20]
DIGEST_SYSTEM_PROMPT = """You are a senior crypto analyst writing a daily trading briefing. Given a list of news headlines and their extracted sentiments, write a concise daily digest in this exact format:

**Overall Sentiment:** [BULLISH/BEARISH/NEUTRAL]
**Key Events:** [2-3 bullet points of the most important news]
**Most Affected Coins:** [comma-separated list]
**Dominant Theme:** [single sentence describing the day's narrative]
**Trader Takeaway:** [1-2 sentence action-oriented summary]

Be concise and actionable. Focus on what matters for price."""


def generate_daily_digest(
    extracted_df: pd.DataFrame,
    client,
    provider: str,
    top_n: int = 10
) -> str:
    """
    Generate a daily trading digest from extracted article insights.

    Selects the top_n highest-impact articles and sends their summaries
    to the LLM for synthesis into a single daily briefing.

    Parameters
    ----------
    extracted_df : Output from extract_article_insights()
    client       : LLM API client
    provider     : 'anthropic' or 'openai'
    top_n        : Number of articles to include in digest

    Returns
    -------
    Digest string from the LLM
    """
    # Prioritize high-impact articles
    priority_order = {'high': 0, 'medium': 1, 'low': 2}
    df = extracted_df.copy()
    if 'impact_level' in df.columns:
        df['_priority'] = df['impact_level'].map(priority_order).fillna(1)
        df = df.sort_values('_priority').head(top_n)

    article_summaries = []
    for _, row in df.iterrows():
        coins = row.get('affected_coins', [])
        coins_str = ', '.join(coins) if isinstance(coins, list) else str(coins)
        summary_line = f"- [{row.get('sentiment', 'neutral').upper()}] {row['title']}. Coins: {coins_str}."
        article_summaries.append(summary_line)

    input_text = '\n'.join(article_summaries)
    digest = call_llm(client, provider, input_text, DIGEST_SYSTEM_PROMPT)
    return digest


if 'sentiment' in extracted_df.columns:
    daily_digest = generate_daily_digest(extracted_df, llm_client, llm_provider)
    print('=== DAILY CRYPTO TRADING DIGEST ===')
    print(f'Date: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}')
    print(daily_digest)
=== DAILY CRYPTO TRADING DIGEST ===
Date: 2026-06-12 06:59 UTC
**Overall Sentiment:** BULLISH
**Key Events:** 
* SpaceX's crypto-traded IPO points upward to a $2.4 trillion valuation
* SpaceX stock is coming to Solana on the same day it lists on Nasdaq
* Elon Musk's SpaceX prices shares at $135, raising $75 billion in largest-ever IPO
**Most Affected Coins:** SOL, BTC, XRP, ETH
**Dominant Theme:** The day's narrative is dominated by SpaceX's massive IPO and its potential impact on the crypto market, coupled with institutional buying and innovative trading solutions.
**Trader Takeaway:** Focus on SOL and BTC as SpaceX's listing on Solana and its IPO valuation could drive prices upward, while also keeping an eye on regulatory developments that may impact the market.

Section 6 — Free Alternative: BART Summarizer

If you don't have an LLM API key, facebook/bart-large-cnn is a free, open-source extractive summarizer. It won't give structured JSON output, but it produces excellent plain-text summaries.

[21]
from transformers import pipeline as hf_pipeline


def summarize_with_bart(
    texts: list[str],
    max_summary_length: int = 80,
    min_summary_length: int = 20
) -> list[str]:
    """
    Summarize a list of texts using BART (no API key required).

    BART is a seq2seq model trained for abstractive summarization.
    It generates new sentences rather than extracting existing ones.

    Parameters
    ----------
    texts              : List of text strings to summarize
    max_summary_length : Maximum tokens in each summary
    min_summary_length : Minimum tokens in each summary

    Returns
    -------
    List of summary strings
    """
    print('Loading BART summarizer (downloads ~1.6GB on first run)...')
    summarizer = hf_pipeline(
        'summarization',
        model='facebook/bart-large-cnn',
        device=0 if torch.cuda.is_available() else -1
    )

    summaries = []
    for text in tqdm(texts, desc='BART summarization'):
        text = text[:1024]  # BART max input length
        if len(text.split()) < 20:
            summaries.append(text)
            continue
        result = summarizer(text, max_length=max_summary_length,
                            min_length=min_summary_length, do_sample=False)
        summaries.append(result[0]['summary_text'])

    return summaries


# Uncomment to use BART instead of LLM API:
# import torch
# bart_summaries = summarize_with_bart(news_df['full_text'].head(10).tolist())
# news_df.loc[:9, 'bart_summary'] = bart_summaries
# news_df[['title', 'bart_summary']].head(5)
print('BART summarizer ready. Uncomment the lines above to run without an LLM API key.')
BART summarizer ready. Uncomment the lines above to run without an LLM API key.

Section 7 — Sentiment Signal from LLM Scores

The sentiment_score field the LLM returns (−1.0 to +1.0) is a richer signal than a simple label. We aggregate these into a daily score, weighted by the LLM-assessed impact_level.

[22]
def build_llm_daily_signal(df: pd.DataFrame) -> pd.DataFrame:
    """
    Aggregate LLM-extracted sentiment scores into a daily signal.

    Impact level is converted to a numeric weight:
    high=3, medium=2, low=1

    Parameters
    ----------
    df : Output from extract_article_insights() with 'sentiment_score' column

    Returns
    -------
    Daily signal DataFrame
    """
    if 'sentiment_score' not in df.columns:
        print('No sentiment_score column — run LLM extraction first.')
        return pd.DataFrame()

    df = df.copy()
    df['published'] = pd.to_datetime(df['published'], utc=True, errors='coerce')
    df.dropna(subset=['published', 'sentiment_score'], inplace=True)
    df['date']           = df['published'].dt.date
    df['sentiment_score'] = pd.to_numeric(df['sentiment_score'], errors='coerce').fillna(0)
    df['impact_weight']  = df['impact_level'].map({'high': 3, 'medium': 2, 'low': 1}).fillna(1)

    daily = df.groupby('date').apply(
        lambda g: pd.Series({
            'llm_weighted_sentiment': (g['sentiment_score'] * g['impact_weight']).sum() / g['impact_weight'].sum(),
            'mean_sentiment_score':   g['sentiment_score'].mean(),
            'article_count':          len(g),
            'high_impact_count':      (g['impact_level'] == 'high').sum(),
            'bullish_count':          (g['sentiment'] == 'bullish').sum(),
            'bearish_count':          (g['sentiment'] == 'bearish').sum(),
            'top_coins':              ', '.join(set([c for coins in g['affected_coins'].dropna()
                                                     for c in (coins if isinstance(coins, list) else [])]))
        })
    ).reset_index()

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


daily_signal = build_llm_daily_signal(extracted_df)
if not daily_signal.empty:
    print(f'Daily signal: {len(daily_signal)} days')
    daily_signal.tail()
Daily signal: 2 days
/tmp/ipykernel_997/1208562795.py:27: 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(

Section 8 — Visualization & Export

[23]
def plot_llm_results(extracted_df: pd.DataFrame) -> None:
    if 'sentiment' not in extracted_df.columns:
        print('No LLM results to plot.')
        return

    fig, axes = plt.subplots(1, 3, figsize=(16, 5))

    # Sentiment distribution
    counts = extracted_df['sentiment'].value_counts()
    axes[0].bar(counts.index, counts.values,
                color=['#2ecc71' if l == 'bullish' else '#e74c3c' if l == 'bearish' else '#95a5a6'
                       for l in counts.index], edgecolor='white')
    axes[0].set_title('LLM Sentiment Labels', fontweight='bold')

    # Event type distribution
    event_counts = extracted_df['event_type'].value_counts().head(6)
    axes[1].barh(event_counts.index, event_counts.values, color='steelblue', edgecolor='white')
    axes[1].set_title('Event Type Distribution', fontweight='bold')

    # Impact level
    impact_counts = extracted_df['impact_level'].value_counts()
    axes[2].pie(impact_counts.values, labels=impact_counts.index, autopct='%1.0f%%',
                colors=['#e74c3c', '#f39c12', '#2ecc71'])
    axes[2].set_title('Impact Level Distribution', fontweight='bold')

    plt.suptitle('LLM-Extracted News Analysis', fontsize=14)
    plt.tight_layout()
    plt.show()


plot_llm_results(extracted_df)

# Export
export_cols = ['source', 'title', 'published', 'sentiment', 'sentiment_score',
               'affected_coins', 'event_type', 'impact_level', 'time_horizon',
               'one_line_summary', 'trading_implication', 'link']
available_cols = [c for c in export_cols if c in extracted_df.columns]
extracted_df[available_cols].to_csv('llm_news_analysis.csv', index=False)

if not daily_signal.empty:
    daily_signal.to_csv('llm_daily_signal.csv', index=False)

print('Export complete.')
cell output
Export complete.

Section 9 — Summary

What This Pipeline Produces

For each article:

  • Sentiment label + numeric score
  • Affected coins
  • Event type classification
  • Impact level
  • Trading implication in plain English

For each day:

  • Impact-weighted daily sentiment signal
  • Count of high-impact events
  • Most-mentioned coins

Recommended Production Setup

Every hour:
  1. Fetch RSS (free, instant)
  2. FinBERT screen all articles (fast, free)
  3. Send top 5 extreme articles to LLM (paid, slow)
  4. Generate daily digest at market open
  5. Store results → merge with OHLCV for strategy features