Build a News-Reactive Trading Bot with Claude Code (Python + NewsAPI)

intermediate 40 min · · By Alpha Guy · claude-code

What You Are Building

A Python bot that pulls crypto news headlines from NewsAPI, scores each headline for sentiment using keyword matching, and places or adjusts positions on Binance based on the aggregate score. The bot checks for new headlines every 5 minutes and executes spot trades. This tutorial covers the full build with Claude Code, including prompts, iterations, and the real limitations of news-based trading.

Prerequisites

  • Python 3.10+
  • A NewsAPI free account (500 requests/day on the free tier)
  • A Binance account with API keys enabled for spot trading
  • Claude Code installed and working
  • Basic familiarity with Python and REST APIs

If you have not set up Binance API keys before, see our Binance API setup guide first.

The Architecture

The bot has four components:

  1. News fetcher — polls NewsAPI for the latest crypto headlines
  2. Sentiment scorer — assigns a sentiment score to each headline
  3. Decision engine — converts aggregate sentiment into a trading signal
  4. Executor — places orders on Binance based on the signal

The data flow is simple: Headlines → Scores → Signal → Order.

Prompt 1: The Initial Skeleton

I started with a broad prompt to get the basic structure:

Write a Python script that:

  1. Fetches the latest 20 crypto news headlines from NewsAPI using the /v2/everything endpoint with query “bitcoin OR ethereum OR crypto”
  2. Scores each headline for sentiment using a keyword-based approach (lists of bullish and bearish keywords)
  3. Calculates an aggregate sentiment score (-1 to +1)
  4. If aggregate score > 0.3, buy BTC; if < -0.3, sell BTC; otherwise hold
  5. Uses python-binance to execute spot market orders
  6. Runs in a loop every 5 minutes
  7. Logs every decision with timestamp, headline scores, aggregate score, and action taken
  8. Load API keys from environment variables, never hardcode them

Claude Code produced a working first draft, but it had two problems. The sentiment keyword lists were too short (10 words each), and there was no error handling around the Binance API calls. I refined with a second prompt.

Prompt 2: Better Sentiment and Error Handling

Improve the news trading bot:

  1. Expand the bullish keywords list to at least 40 words including terms like “rally”, “surge”, “breakout”, “adoption”, “approval”, “accumulation”, “institutional”, “upgrade”, “partnership”, “bullish”, “recovery”, “momentum”, “support”, “inflow”, etc.
  2. Expand the bearish keywords list similarly with “crash”, “dump”, “hack”, “ban”, “regulation”, “lawsuit”, “outflow”, “bearish”, “resistance”, “liquidation”, “fraud”, “collapse”, etc.
  3. Add weighted keywords — “SEC approval” should score higher than generic “bullish”
  4. Wrap all Binance API calls in try/except with specific error types (BinanceAPIException, BinanceOrderException)
  5. Add a position tracker so the bot knows if it’s already long and doesn’t double-buy
  6. Add a cooldown period — no new trades within 15 minutes of the last trade

This produced a much better version. One more round to add the risk management layer.

Prompt 3: Risk Management

Add risk management to the news trading bot:

  1. Maximum position size: configurable, default 0.001 BTC per trade
  2. Daily loss limit: if total realized losses exceed $50 in a day, stop trading until next UTC midnight
  3. Add a simple trailing stop: if the position drops 2% from entry, market sell immediately
  4. Log the current portfolio value at each check cycle

The Full Code

Here is the complete bot after three rounds of iteration. I made a few manual edits after Claude Code’s output — noted in the comments.

import os
import time
import logging
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass, field
import requests
from binance.client import Client
from binance.exceptions import BinanceAPIException, BinanceOrderException

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("news_bot.log"),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

# ─── Configuration ────────────────────────────────────────
NEWS_API_KEY = os.environ["NEWS_API_KEY"]
BINANCE_API_KEY = os.environ["BINANCE_API_KEY"]
BINANCE_SECRET = os.environ["BINANCE_API_SECRET"]

SYMBOL = "BTCUSDT"
TRADE_QTY = float(os.environ.get("TRADE_QTY", "0.001"))
CHECK_INTERVAL = 300  # 5 minutes
SENTIMENT_THRESHOLD = 0.3
COOLDOWN_SECONDS = 900  # 15 minutes
DAILY_LOSS_LIMIT = 50.0  # USD
TRAILING_STOP_PCT = 0.02  # 2%

# ─── Sentiment Keywords ──────────────────────────────────
# Weighted: (keyword, weight). Higher weight = stronger signal.
BULLISH_KEYWORDS = [
    ("sec approval", 3), ("etf approved", 3), ("institutional adoption", 3),
    ("strategic reserve", 3), ("legal victory", 2.5),
    ("rally", 2), ("surge", 2), ("breakout", 2), ("soars", 2),
    ("all-time high", 2.5), ("ath", 2), ("moon", 1.5),
    ("adoption", 1.5), ("accumulation", 1.5), ("upgrade", 1.5),
    ("partnership", 1.5), ("integration", 1.5), ("launch", 1.5),
    ("bullish", 1.5), ("recovery", 1.5), ("momentum", 1),
    ("support", 1), ("inflow", 1.5), ("buying", 1),
    ("growth", 1), ("optimism", 1), ("confidence", 1),
    ("demand", 1), ("uptrend", 1.5), ("gains", 1),
    ("positive", 1), ("breakthrough", 1.5), ("milestone", 1),
    ("record", 1), ("expansion", 1), ("innovation", 1),
    ("backing", 1), ("endorsement", 1.5), ("mainstream", 1),
    ("halving", 1.5), ("scarcity", 1),
]

BEARISH_KEYWORDS = [
    ("sec lawsuit", 3), ("exchange hack", 3), ("rug pull", 3),
    ("ponzi", 3), ("fraud charges", 3),
    ("crash", 2.5), ("dump", 2), ("plunge", 2), ("tank", 2),
    ("collapse", 2.5), ("ban", 2), ("crackdown", 2),
    ("hack", 2), ("exploit", 2), ("vulnerability", 1.5),
    ("regulation", 1), ("lawsuit", 2), ("investigation", 1.5),
    ("outflow", 1.5), ("bearish", 1.5), ("resistance", 1),
    ("liquidation", 2), ("sell-off", 2), ("selloff", 2),
    ("fear", 1.5), ("uncertainty", 1), ("decline", 1.5),
    ("drop", 1), ("loss", 1), ("warning", 1.5),
    ("risk", 1), ("concern", 1), ("negative", 1),
    ("bubble", 1.5), ("overvalued", 1.5), ("correction", 1.5),
    ("sanctions", 2), ("shutdown", 2), ("withdrawal", 1),
    ("delay", 1), ("rejection", 1.5),
]


@dataclass
class BotState:
    is_long: bool = False
    entry_price: float = 0.0
    last_trade_time: datetime = field(
        default_factory=lambda: datetime(2000, 1, 1, tzinfo=timezone.utc)
    )
    daily_loss: float = 0.0
    loss_reset_date: str = ""
    peak_price: float = 0.0  # for trailing stop


def fetch_headlines() -> list[dict]:
    """Fetch latest crypto headlines from NewsAPI."""
    url = "https://newsapi.org/v2/everything"
    params = {
        "q": "bitcoin OR ethereum OR crypto",
        "language": "en",
        "sortBy": "publishedAt",
        "pageSize": 20,
        "apiKey": NEWS_API_KEY,
    }
    try:
        resp = requests.get(url, params=params, timeout=10)
        resp.raise_for_status()
        articles = resp.json().get("articles", [])
        return articles
    except requests.RequestException as e:
        logger.error(f"NewsAPI request failed: {e}")
        return []


def score_headline(title: str) -> float:
    """Score a single headline. Positive = bullish, negative = bearish."""
    if not title:
        return 0.0
    title_lower = title.lower()
    score = 0.0
    for keyword, weight in BULLISH_KEYWORDS:
        if keyword in title_lower:
            score += weight
    for keyword, weight in BEARISH_KEYWORDS:
        if keyword in title_lower:
            score -= weight
    return score


def aggregate_sentiment(articles: list[dict]) -> tuple[float, list[dict]]:
    """
    Score all headlines and return normalized aggregate score (-1 to +1)
    plus individual scores for logging.
    """
    scored = []
    total_score = 0.0
    for article in articles:
        title = article.get("title", "")
        s = score_headline(title)
        scored.append({"title": title, "score": s})
        total_score += s

    if not scored:
        return 0.0, scored

    # Normalize: divide by count, then clamp to [-1, 1]
    avg = total_score / len(scored)
    normalized = max(-1.0, min(1.0, avg / 3.0))  # /3 scaling factor
    return normalized, scored


def get_current_price(client: Client) -> float:
    """Get current BTC/USDT price."""
    ticker = client.get_symbol_ticker(symbol=SYMBOL)
    return float(ticker["price"])


def execute_buy(client: Client, state: BotState) -> bool:
    """Execute a market buy order."""
    try:
        order = client.order_market_buy(symbol=SYMBOL, quantity=TRADE_QTY)
        fill_price = float(order["fills"][0]["price"])
        state.is_long = True
        state.entry_price = fill_price
        state.peak_price = fill_price
        state.last_trade_time = datetime.now(timezone.utc)
        logger.info(f"BUY executed: {TRADE_QTY} BTC at ${fill_price:,.2f}")
        return True
    except BinanceAPIException as e:
        logger.error(f"Binance API error on buy: {e.message}")
        return False
    except BinanceOrderException as e:
        logger.error(f"Binance order error on buy: {e.message}")
        return False


def execute_sell(client: Client, state: BotState, reason: str) -> bool:
    """Execute a market sell order."""
    try:
        order = client.order_market_sell(symbol=SYMBOL, quantity=TRADE_QTY)
        fill_price = float(order["fills"][0]["price"])

        # Track P&L
        if state.entry_price > 0:
            pnl = (fill_price - state.entry_price) * TRADE_QTY
            logger.info(f"Trade P&L: ${pnl:,.2f}")
            if pnl < 0:
                state.daily_loss += abs(pnl)

        state.is_long = False
        state.entry_price = 0.0
        state.peak_price = 0.0
        state.last_trade_time = datetime.now(timezone.utc)
        logger.info(
            f"SELL executed ({reason}): {TRADE_QTY} BTC at ${fill_price:,.2f}"
        )
        return True
    except BinanceAPIException as e:
        logger.error(f"Binance API error on sell: {e.message}")
        return False
    except BinanceOrderException as e:
        logger.error(f"Binance order error on sell: {e.message}")
        return False


def check_trailing_stop(client: Client, state: BotState) -> bool:
    """Check if trailing stop has been hit. Returns True if sold."""
    if not state.is_long:
        return False

    current_price = get_current_price(client)
    # Update peak
    if current_price > state.peak_price:
        state.peak_price = current_price

    # Check if price dropped TRAILING_STOP_PCT from peak
    drop_pct = (state.peak_price - current_price) / state.peak_price
    if drop_pct >= TRAILING_STOP_PCT:
        logger.warning(
            f"Trailing stop triggered: price ${current_price:,.2f}, "
            f"peak ${state.peak_price:,.2f}, drop {drop_pct:.2%}"
        )
        return execute_sell(client, state, reason="trailing_stop")
    return False


def run_bot():
    """Main bot loop."""
    client = Client(BINANCE_API_KEY, BINANCE_SECRET)
    state = BotState()

    logger.info("News-reactive trading bot started")
    logger.info(f"Symbol: {SYMBOL}, Trade size: {TRADE_QTY} BTC")
    logger.info(f"Sentiment threshold: +/-{SENTIMENT_THRESHOLD}")
    logger.info(f"Check interval: {CHECK_INTERVAL}s")

    while True:
        try:
            now = datetime.now(timezone.utc)
            today_str = now.strftime("%Y-%m-%d")

            # Reset daily loss counter at UTC midnight
            if state.loss_reset_date != today_str:
                if state.daily_loss > 0:
                    logger.info(
                        f"Daily loss reset. Yesterday's loss: ${state.daily_loss:.2f}"
                    )
                state.daily_loss = 0.0
                state.loss_reset_date = today_str

            # Check daily loss limit
            if state.daily_loss >= DAILY_LOSS_LIMIT:
                logger.warning(
                    f"Daily loss limit reached (${state.daily_loss:.2f}). "
                    f"Paused until UTC midnight."
                )
                time.sleep(CHECK_INTERVAL)
                continue

            # Check trailing stop first
            if check_trailing_stop(client, state):
                time.sleep(CHECK_INTERVAL)
                continue

            # Fetch and score headlines
            articles = fetch_headlines()
            if not articles:
                logger.warning("No articles fetched, skipping cycle")
                time.sleep(CHECK_INTERVAL)
                continue

            sentiment, scored_headlines = aggregate_sentiment(articles)
            logger.info(f"Aggregate sentiment: {sentiment:+.3f}")

            # Log top movers
            for item in sorted(scored_headlines, key=lambda x: abs(x["score"]),
                               reverse=True)[:5]:
                if item["score"] != 0:
                    logger.info(f"  [{item['score']:+.1f}] {item['title']}")

            # Check cooldown
            since_last = (now - state.last_trade_time).total_seconds()
            if since_last < COOLDOWN_SECONDS:
                remaining = int(COOLDOWN_SECONDS - since_last)
                logger.info(f"Cooldown active: {remaining}s remaining")
                time.sleep(CHECK_INTERVAL)
                continue

            # Decision logic
            if sentiment > SENTIMENT_THRESHOLD and not state.is_long:
                logger.info(f"Bullish signal ({sentiment:+.3f}), executing buy")
                execute_buy(client, state)

            elif sentiment < -SENTIMENT_THRESHOLD and state.is_long:
                logger.info(f"Bearish signal ({sentiment:+.3f}), executing sell")
                execute_sell(client, state, reason="bearish_sentiment")

            else:
                action = "holding long" if state.is_long else "no position"
                logger.info(f"No action ({action}, sentiment {sentiment:+.3f})")

            # Log portfolio value
            current_price = get_current_price(client)
            if state.is_long:
                unrealized = (current_price - state.entry_price) * TRADE_QTY
                logger.info(
                    f"Position: {TRADE_QTY} BTC @ ${state.entry_price:,.2f}, "
                    f"current ${current_price:,.2f}, "
                    f"unrealized P&L: ${unrealized:,.2f}"
                )

        except KeyboardInterrupt:
            logger.info("Bot stopped by user")
            break
        except Exception as e:
            logger.error(f"Unexpected error: {e}", exc_info=True)

        time.sleep(CHECK_INTERVAL)


if __name__ == "__main__":
    run_bot()

Setting Up and Running

Install the dependencies:

pip install python-binance requests

Set your environment variables:

export NEWS_API_KEY="your_newsapi_key"
export BINANCE_API_KEY="your_binance_api_key"
export BINANCE_API_SECRET="your_binance_api_secret"
export TRADE_QTY="0.001"  # optional, defaults to 0.001 BTC

Run the bot:

python news_bot.py

The bot logs every cycle to both the console and news_bot.log. You will see output like this:

2026-04-10 14:30:00 [INFO] Aggregate sentiment: +0.187
2026-04-10 14:30:00 [INFO]   [+2.0] Bitcoin surges past $90K as institutional demand grows
2026-04-10 14:30:00 [INFO]   [-2.0] SEC launches investigation into major crypto exchange
2026-04-10 14:30:00 [INFO]   [+1.5] Ethereum upgrade successfully deployed on mainnet
2026-04-10 14:30:00 [INFO] No action (no position, sentiment +0.187)

How the Sentiment Scoring Works

The scoring is deliberately simple. Each headline gets checked against two keyword lists. If a headline contains “surge” (weight 2.0) and “institutional” (weight 1.5), it scores +3.5. If another headline contains “crash” (weight 2.5), it scores -2.5. The aggregate is the average score across all 20 headlines, normalized to a -1 to +1 range.

This approach is crude compared to what you could do with an LLM-based sentiment classifier. I chose keyword matching for three reasons:

  1. Speed — keyword matching is instant, no API call needed
  2. Cost — no per-headline LLM inference costs
  3. Predictability — you know exactly why a headline scored the way it did

The tradeoff is accuracy. The keyword approach misses context entirely. “Bitcoin crashes through $90K resistance” is bullish (price breaking above resistance), but the keyword “crash” makes it score negative. In practice, these misclassifications average out across 20 headlines, but individual scores can be wrong.

If you want higher accuracy, you can replace the score_headline function with an LLM call. Ask Claude Code:

Replace the keyword-based sentiment scorer with a function that sends each headline to Claude’s API and asks for a sentiment score from -1 to +1 with a one-sentence reasoning.

This improves accuracy substantially but adds latency and cost per cycle.

When News Trading Works

News trading works in specific conditions:

Genuine surprise events. When something unexpected happens — a regulatory ruling nobody anticipated, a major exchange hack, a sudden ETF approval — the market reprices quickly. If your bot catches the headline before the market fully reacts, there is alpha.

Trending narratives. When multiple news outlets run stories with the same bullish or bearish tone over hours, sentiment becomes self-reinforcing. The aggregate score captures this trend well.

Low-liquidity periods. News impact is amplified during Asian or European sessions for pairs that primarily trade during US hours. The bot is equally attentive across all hours, which is an edge over manual traders who sleep.

When News Trading Does Not Work

Be honest with yourself about the limitations.

Speed disadvantage. Professional news trading firms use direct data feeds, co-located servers, and custom NLP models. They react in milliseconds. This bot checks NewsAPI every 5 minutes. By the time you see a headline, the price has often already moved. You are not competing with HFT firms — you are picking up what is left after they have acted.

Keyword limitations. The sentiment model has no concept of context, sarcasm, or nuance. “Bitcoin is NOT crashing” would score negative because it contains “crashing.” Headlines like “Despite crash fears, Bitcoin holds steady” send mixed signals that the keyword approach handles poorly.

Noise. Most crypto news is noise. A headline about a minor partnership or a small exchange listing moves the needle by zero. The bot treats all headlines equally within the keyword weighting, which dilutes real signals with irrelevant ones.

Correlation with price. By the time a “Bitcoin rallies” headline is published, the rally has already happened. News lags price more often than it leads price. This is the single biggest limitation of news-based trading.

Risk Management Details

The bot has three safety mechanisms, and I want to be clear that these are minimum safeguards, not comprehensive risk management.

Position tracking. The bot will not double-buy. If it is already long and sentiment stays bullish, it holds rather than adding to the position. This prevents pyramid stacking on a sequence of bullish headlines.

Daily loss limit. If cumulative realized losses hit $50 in a UTC day, the bot stops trading until the next day. This prevents a catastrophic session where a flurry of whipsaw signals racks up losses. The $50 default is conservative for a 0.001 BTC position — adjust based on your risk tolerance.

Trailing stop. Once long, the bot tracks the highest price reached and sells if price drops 2% from that peak. This protects gains during reversals and limits downside on new positions. The 2% threshold is tight for crypto — BTC regularly moves 2% intraday. You may want to set this to 3-5% depending on volatility conditions.

What is missing: there is no maximum drawdown circuit breaker across multiple days, no correlation-based position sizing, and no slippage estimation. For a bot trading 0.001 BTC, these omissions are acceptable. For larger sizes, you need more sophisticated risk controls.

Possible Improvements

Some modifications worth exploring:

  • Add more news sources — NewsAPI is one provider. You could add CryptoPanic, CoinDesk RSS, or Twitter/X sentiment via another API to widen coverage.
  • Multi-asset — Extend the bot to trade ETH, SOL, or other assets based on asset-specific keyword matches.
  • LLM scoring — Replace keyword matching with Claude API calls for context-aware sentiment.
  • Backtest mode — Add a flag that reads historical headlines from a CSV instead of live API, so you can test parameter changes against past data.

Next Steps

Disclaimer: This article is for educational purposes only and is not financial advice. Trading cryptocurrencies involves substantial risk of loss. Past performance does not guarantee future results. Always do your own research before making any trading decisions. Read full disclaimer →
Alpha Guy
Alpha Guy

Founder of VibeTradingLab. Ex-Goldman Sachs engineer, 2025 Binance Top 1% Trader. Writes about using AI tools to build trading systems that actually work. Currently nomading between Bali, Dubai, and the Mediterranean.

Got stuck? Have questions?

Join our Telegram group to ask questions, share your bots, and connect with other AI traders.

Join Telegram