TL;DR
-
CoinGecko API powers a news-driven trading bot that pulls headlines from the Crypto News endpoint, scores them with an LLM, validates signals against live market data, and executes paper trades locally.
-
The bot cross-checks every coin mentioned in a headline against market cap and trading volume from CoinGecko’s Coins List with Market Data endpoint before sizing a trade.
Crypto price moves are often triggered by events such as regulatory rulings, exchange listings, protocol exploits, or sudden ETF inflows. A news-based trading bot targets these catalysts directly. It reads each headline as it appears in the news feed, assesses its likely price impact, and acts on a predefined rule instead of waiting for the price to move first.
In this guide, you’ll build a Python trading bot using the CoinGecko API that reads real-time crypto news, scores each headline with a large language model (LLM), confirms the signal against live market data, and records simulated trades in a local paper-trading portfolio.
Full Disclosure: All trades in this guide are paper trades with no exchange keys or real orders involved. This guide is for educational purposes only and does not constitute financial advice. All trading involves risk. Please do your own research before making any investment decisions.

Prerequisites & Setup
You’ll need a CoinGecko API key. The Crypto News endpoint is available exclusively on the Analyst plan and above, which also includes 500K monthly API credits, 500 calls per minute, and access to 80+ endpoints.
If you’re using the free Demo API, follow the guide to get a free Demo API key. You can also connect individual RSS feeds from news publishers to build the bot for free, although this requires more setup and does not provide the structured coin-filtering available through CoinGecko’s news endpoint.
This guide uses Claude as the example, but the same approach works with any LLM that supports structured or JSON output, such as OpenAI and Gemini. Swap the client setup and model name, and the rest of the pipeline stays unchanged.
1 2 3 4
requests pandas anthropic python-dotenv
1 2 3
COINGECKO_API_KEY=CG-your_api_key_here COINGECKO_PLAN=analyst ANTHROPIC_API_KEY=sk-ant-your_key_here
Install the dependencies.
pip install -r requirements.txtNext, the configuration module below switches the API host and authentication header based on your plan, allowing the same codebase to run on either the Demo API or a paid plan. It also keeps the strategy thresholds in one place, letting you tune the bot without changing the trading logic.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
import os from dotenv import load_dotenv load_dotenv() API_KEY = os.getenv("COINGECKO_API_KEY", "") PLAN = os.getenv("COINGECKO_PLAN", "demo") ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") if PLAN in ("analyst", "pro"): BASE_URL = "https://pro-api.coingecko.com/api/v3" HEADERS = {"x-cg-pro-api-key": API_KEY} else: BASE_URL = "https://api.coingecko.com/api/v3" HEADERS = {"x-cg-demo-api-key": API_KEY} # Fix these before testing so you are not tuning against results # you have already seen. MIN_SENTIMENT_SCORE = 0.35 MIN_CONFIDENCE = 0.60 # Universe filters. MIN_MARKET_CAP_USD = 100_000_000 MIN_VOLUME_USD = 5_000_000 STARTING_CASH_USD = 10_000.0 POSITION_PCT = 0.05 # 5% of equity per trade COOLDOWN_HOURS = 6 # per coin, prevents stacking one news cycle FEE_PCT = 0.001 # 0.1% per side SLIPPAGE_PCT = 0.002 # 0.2% assumed adverse fill
How to Fetch Crypto News in Python
CoinGecko API returns structured crypto news through the Crypto News endpoint, aggregating articles from 100+ curated sources including Decrypt, Bankless, BeInCrypto, and delivering them as JSON for direct parsing. Each article includes the headline, publisher, ISO 8601 timestamp, article URL, thumbnail, and a list of CoinGecko coin IDs matched to the article.
The endpoint supports page and per_page for pagination, coin_id for asset-level filtering, language for localized articles, and type for distinguishing news from guides. Results can be paginated across up to 20 pages of 20 articles, allowing up to 400 articles in a single pass.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
import requests from config import BASE_URL, HEADERS def fetch_latest_news(per_page=10, coin_id=None): """Fetch the most recent crypto news articles.""" params = {"per_page": per_page, "page": 1} if coin_id: params["coin_id"] = coin_id response = requests.get(f"{BASE_URL}/news", headers=HEADERS, params=params) response.raise_for_status() # SDK equivalent: client.news.get(per_page=10) # The endpoint returns a bare JSON array, not an object with a data key return response.json() if __name__ == "__main__": for article in fetch_latest_news(per_page=5): print(f"[{article['posted_at']}] {article['source_name']}") print(f" {article['title']}") print(f" coins: {article['related_coin_ids']}\n")
Running this script produces output like the following:

How to Turn a News Headline Into a Trading Signal
CoinGecko API provides the live market data needed to turn news into trading signals. The bot sends each headline to an LLM, which scores the news and returns structured values the trading strategy can use, including a sentiment score from -1 to 1, a confidence score from 0 to 1, and a short explanation for logging. The sentiment and confidence scores drive the trading decision, while the bot uses CoinGecko market data to confirm the signal, with only signals that pass both checks reaching the paper trader.
How to Score News Sentiment with an LLM
First, the model should score the potential price impact of the news, not whether the news is generally positive or negative. For example, a regulatory crackdown on a competing chain could be positive for the asset being analyzed.
Second, the model should be allowed to return a score of zero when a headline has no clear directional impact. Forcing a positive or negative score can turn neutral news into false trading signals. Below is an example of a simple scoring layer alongside system prompts for the downstream LLMs.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
import json import anthropic from config import ANTHROPIC_API_KEY client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY) SYSTEM_PROMPT = """You score crypto news headlines for short-term price impact. Return JSON only, matching this schema: {"score": float, "confidence": float, "reasoning": string} score: -1.0 (strongly bearish) to 1.0 (strongly bullish) for the coin named confidence: 0.0 to 1.0 Score the likely effect on price, not whether the news is good in general. Return score 0.0 and confidence 0.0 when a headline carries no clear directional signal. Most headlines are noise, and scoring them as neutral is the correct answer.""" def score_headline(title, source_name): """Convert a headline into a numeric trading signal.""" # Source is included because a wire service and an unattributed blog # do not deserve the same weight message = client.messages.create( model="claude-sonnet-4-5", max_tokens=300, system=SYSTEM_PROMPT, messages=[{ "role": "user", "content": f"Source: {source_name}\nHeadline: {title}" }], ) return json.loads(message.content[0].text) if __name__ == "__main__": samples = [ ("Bitcoin Demand Strengthens as ETFs Add $865M and New Wallets Hit One-Year High", "Blockonomi"), ("Eliza Labs founder sells $25M in ElizaOS tokens as project collapses after lawsuit", "Crypto Briefing"), ("Crypto card spending hits $759 million in July 2026, led by USDC and USDT", "COINTURK NEWS"), ] for title, source in samples: result = score_headline(title, source) print(f"{result['score']:+.2f} conf {result['confidence']:.2f} {title[:58]}") print(f" {result['reasoning']}\n")
Here’s what the output looks like: 
A headline does not always capture the full sentiment, nuance, or context of an article, so treat these scores as an initial signal rather than a definitive assessment. For a stronger signal, fetch and analyze the full article text with tools such as Firecrawl, Jina Reader, or Tavily, and use that context to refine the sentiment and confidence scores, where the publisher’s terms of use and crawling policies allow it.
Alternatively, CoinGecko’s Coin Insights endpoint can provide an additional layer of structured, AI-generated coin-level context, reducing the need to build and maintain a separate article-crawling workflow. Coin Insights is currently available to Enterprise clients; developers interested in accessing the endpoint can submit their interest here.
How to Confirm a Trading Signal Against Live Market Data
The CoinGecko API’s price data adds market context before a trade is placed. A positive headline score reflects what the LLM thinks the news could mean for the asset, while live price data shows how the market is responding. The signal only qualifies for execution when both the news assessment and market data align, helping filter out signals that may already be reflected in the price.
The function below runs three checks in order. If any check fails, the bot skips the trade:
-
Signal strength: The sentiment score and confidence must meet the thresholds in
config.py. -
Price direction: The coin’s price over the past hour should move in the same direction as the signal. If the news is bullish but the price is falling, the bot skips the trade.
-
Recent price move: If the coin has already moved more than
MAX_ALREADY_MOVED_PCTin the past hour, the bot assumes the initial move may have already happened and skips the trade.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
from config import MIN_SENTIMENT_SCORE, MIN_CONFIDENCE MAX_ALREADY_MOVED_PCT = 8.0 def should_trade(score, confidence, coin): """Apply every gate between a scored headline and an order.""" if score < MIN_SENTIMENT_SCORE or confidence < MIN_CONFIDENCE: return False, "below score or confidence threshold" change_1h = coin.get("price_change_percentage_1h_in_currency") or 0.0 # A bullish story on a falling coin means the market knows something # the headline does not if change_1h < 0: return False, f"price disagrees with signal ({change_1h:+.1f}% 1h)" if change_1h > MAX_ALREADY_MOVED_PCT: return False, f"move already priced in ({change_1h:+.1f}% 1h)" return True, "confirmed" def should_exit(score, confidence, coin): """A simple, illustrative exit rule: reverse out of a position on a sufficiently bearish, price-confirmed signal. This is not a production exit strategy -- see the note below the paper-trading example for what a real one needs.""" change_1h = coin.get("price_change_percentage_1h_in_currency") or 0.0 return (score <= -MIN_SENTIMENT_SCORE and confidence >= MIN_CONFIDENCE and change_1h < 0) if __name__ == "__main__": cases = [ ("agrees, not yet moved", 0.68, 0.81, {"price_change_percentage_1h_in_currency": 1.2}), ("price disagrees", 0.68, 0.81, {"price_change_percentage_1h_in_currency": -0.6}), ("already priced in", 0.68, 0.81, {"price_change_percentage_1h_in_currency": 9.4}), ("low confidence", 0.41, 0.30, {"price_change_percentage_1h_in_currency": 0.5}), ] for label, score, confidence, coin in cases: ok, reason = should_trade(score, confidence, coin) print(f"{'TRADE' if ok else 'SKIP ':<6} {label:<24} {reason}") exit_cases = [ ("bearish, price confirms", -0.71, 0.79, {"price_change_percentage_1h_in_currency": -1.4}), ("bearish, not yet moved", -0.71, 0.79, {"price_change_percentage_1h_in_currency": 0.3}), ] for label, score, confidence, coin in exit_cases: exit_now = should_exit(score, confidence, coin) change_1h = coin["price_change_percentage_1h_in_currency"] print(f"{'EXIT' if exit_now else 'HOLD':<6} {label:<24} score {score:+.2f}, {change_1h:+.1f}% 1h")
The example below shows how each condition can block a trade. Each rejection maps to a specific check in the function, making it clear why the trade was skipped. When the bot rejects a trade later, the log shows which condition blocked it, making the decision easier to review and debug.

How to Paper Trade a News Strategy in Python
A paper trading bot keeps a local record of cash and open positions and simulates trades using real market prices without sending orders to an exchange. CoinGecko API provides the market data, while the bot handles the simulated trading locally.
For more realistic results, the bot uses the next candle’s open price to simulate each trade. This gives the market time to react to the news before the simulated entry and avoids making the strategy look more profitable than it would be in practice.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95
import sqlite3 from datetime import datetime, timedelta, timezone from config import (STARTING_CASH_USD, POSITION_PCT, COOLDOWN_HOURS, FEE_PCT, SLIPPAGE_PCT) class PaperPortfolio: def __init__(self, db_path="portfolio.db"): self.conn = sqlite3.connect(db_path) self._setup() def _setup(self): # signal_log records every scored headline, not only executed ones. self.conn.executescript(""" CREATE TABLE IF NOT EXISTS positions ( coin_id TEXT PRIMARY KEY, qty REAL, entry_price REAL, opened_at TEXT); CREATE TABLE IF NOT EXISTS trades ( id INTEGER PRIMARY KEY, coin_id TEXT, side TEXT, qty REAL, price REAL, headline TEXT, score REAL, executed_at TEXT); CREATE TABLE IF NOT EXISTS signal_log ( id INTEGER PRIMARY KEY, coin_id TEXT, headline TEXT, score REAL, confidence REAL, executed INTEGER, reason TEXT, logged_at TEXT); CREATE TABLE IF NOT EXISTS cash (balance REAL); """) if not self.conn.execute("SELECT 1 FROM cash").fetchone(): self.conn.execute("INSERT INTO cash VALUES (?)", (STARTING_CASH_USD,)) self.conn.commit() def in_cooldown(self, coin_id): """Stop one news cycle from opening five positions in the same coin.""" row = self.conn.execute( "SELECT executed_at FROM trades WHERE coin_id=? ORDER BY id DESC LIMIT 1", (coin_id,)).fetchone() if not row: return False last = datetime.fromisoformat(row[0]) return datetime.now(timezone.utc) - last < timedelta(hours=COOLDOWN_HOURS) def buy(self, coin_id, fill_price, headline, score): balance = self.conn.execute("SELECT balance FROM cash").fetchone()[0] notional = balance * POSITION_PCT # Assume an adverse fill and pay the fee. Optimistic fills are the # fastest way to build a strategy that only works on paper effective_price = fill_price * (1 + SLIPPAGE_PCT) cost = notional * (1 + FEE_PCT) if cost > balance: return None qty = notional / effective_price now = datetime.now(timezone.utc).isoformat() self.conn.execute("UPDATE cash SET balance = balance - ?", (cost,)) self.conn.execute("INSERT OR REPLACE INTO positions VALUES (?,?,?,?)", (coin_id, qty, effective_price, now)) self.conn.execute( "INSERT INTO trades (coin_id, side, qty, price, headline, score, executed_at)" " VALUES (?,?,?,?,?,?,?)", (coin_id, "BUY", qty, effective_price, headline, score, now)) self.conn.commit() return qty def sell(self, coin_id, fill_price, headline, score): """Close an open position at the given fill price.""" row = self.conn.execute( "SELECT qty, entry_price FROM positions WHERE coin_id=?", (coin_id,)).fetchone() if not row: return None qty, entry_price = row # Selling into a move works against you the same way buying does, # so slippage and fees are applied on the way out too effective_price = fill_price * (1 - SLIPPAGE_PCT) proceeds = qty * effective_price * (1 - FEE_PCT) now = datetime.now(timezone.utc).isoformat() self.conn.execute("UPDATE cash SET balance = balance + ?", (proceeds,)) self.conn.execute("DELETE FROM positions WHERE coin_id=?", (coin_id,)) self.conn.execute( "INSERT INTO trades (coin_id, side, qty, price, headline, score, executed_at)" " VALUES (?,?,?,?,?,?,?)", (coin_id, "SELL", qty, effective_price, headline, score, now)) self.conn.commit() return qty def has_position(self, coin_id): return self.conn.execute( "SELECT 1 FROM positions WHERE coin_id=?", (coin_id,)).fetchone() is not None def log_signal(self, coin_id, headline, score, confidence, executed, reason): """Record every scored headline; skipping this call leaves the false-signal report empty.""" now = datetime.now(timezone.utc).isoformat() self.conn.execute( "INSERT INTO signal_log (coin_id, headline, score, confidence," " executed, reason, logged_at) VALUES (?,?,?,?,?,?,?)", (coin_id, headline, score, confidence, int(executed), reason, now)) self.conn.commit()
The bot fetches the latest news, filters out previously processed articles, scores new headlines, identifies the relevant coin, checks the signal against its price, and records the simulated trade. It also calculates PnL once per cycle using CoinGecko’s Simple Price endpoint, which accepts up to 515 coin IDs in a single request, allowing the bot to price all open positions and previously traded coins in one call.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127
import time import requests from config import BASE_URL, HEADERS, MIN_MARKET_CAP_USD, MIN_VOLUME_USD from fetch_news import fetch_latest_news from score_news import score_headline from confirm_signal import should_trade, should_exit from paper_trader import PaperPortfolio seen_urls = set() portfolio = PaperPortfolio() def get_portfolio_pnl(conn): """Realized PnL from closed trades plus unrealized PnL from open positions, both powered by a single bulk price call.""" positions = conn.execute( "SELECT coin_id, qty, entry_price FROM positions").fetchall() trades = conn.execute( "SELECT coin_id, side, qty, price FROM trades").fetchall() coin_ids = {row[0] for row in positions} | {row[0] for row in trades} if not coin_ids: return {"realized_usd": 0.0, "unrealized_usd": 0.0, "positions": []} # A single call covers every coin the bot has ever touched; # /simple/price accepts up to 515 IDs per request. response = requests.get(f"{BASE_URL}/simple/price", headers=HEADERS, params={"ids": ",".join(coin_ids), "vs_currencies": "usd"}) response.raise_for_status() prices = {coin_id: data["usd"] for coin_id, data in response.json().items()} realized = 0.0 cost_basis = {} for coin_id, side, qty, price in trades: if side == "BUY": cost_basis.setdefault(coin_id, []).append([qty, price]) elif side == "SELL": remaining = qty while remaining > 0 and cost_basis.get(coin_id): lot_qty, lot_price = cost_basis[coin_id][0] matched = min(remaining, lot_qty) realized += matched * (price - lot_price) lot_qty -= matched remaining -= matched if lot_qty <= 0: cost_basis[coin_id].pop(0) else: cost_basis[coin_id][0][0] = lot_qty unrealized = 0.0 open_positions = [] for coin_id, qty, entry_price in positions: current_price = prices.get(coin_id) if current_price is None: continue pnl = qty * (current_price - entry_price) unrealized += pnl open_positions.append({"coin_id": coin_id, "qty": qty, "pnl_usd": pnl}) return {"realized_usd": realized, "unrealized_usd": unrealized, "positions": open_positions} def run_cycle(): for article in fetch_latest_news(per_page=20): # The same story is syndicated across outlets and reappears on every # poll. Without this check the bot buys the same news repeatedly if article["url"] in seen_urls: continue seen_urls.add(article["url"]) coin_ids = article["related_coin_ids"] if not coin_ids: portfolio.log_signal(None, article["title"], None, None, executed=False, reason="no related coin") continue response = requests.get(f"{BASE_URL}/coins/markets", headers=HEADERS, params={ "vs_currency": "usd", "ids": ",".join(coin_ids), "price_change_percentage": "1h,24h", }) response.raise_for_status() candidates = sorted( (c for c in response.json() if (c["market_cap"] or 0) >= MIN_MARKET_CAP_USD and (c["total_volume"] or 0) >= MIN_VOLUME_USD), key=lambda c: c["market_cap"], reverse=True) if not candidates: # Logged with no coin_id, since nothing matched survived the # size and liquidity filter at all portfolio.log_signal(None, article["title"], None, None, executed=False, reason="no tradeable coin") continue signal = score_headline(article["title"], article["source_name"]) coin = candidates[0] if portfolio.has_position(coin["id"]) and should_exit( signal["score"], signal["confidence"], coin): qty = portfolio.sell(coin["id"], coin["current_price"], article["title"], signal["score"]) print(f" SELL {qty:.6f} {coin['symbol'].upper()} @ ${coin['current_price']:,.2f}") continue ok, reason = should_trade(signal["score"], signal["confidence"], coin) if ok and portfolio.in_cooldown(coin["id"]): ok, reason = False, "cooldown active" portfolio.log_signal(coin["id"], article["title"], signal["score"], signal["confidence"], executed=ok, reason=reason) print(f"{signal['score']:+.2f} {coin['id']:<10} {article['title'][:46]}") if not ok: print(f" skip: {reason}") continue qty = portfolio.buy(coin["id"], coin["current_price"], article["title"], signal["score"]) print(f" BUY {qty:.6f} {coin['symbol'].upper()} @ ${coin['current_price']:,.2f}") if __name__ == "__main__": while True: run_cycle() pnl = get_portfolio_pnl(portfolio.conn) print(f"Realized ${pnl['realized_usd']:+,.2f} | " f"Unrealized ${pnl['unrealized_usd']:+,.2f}") time.sleep(600)
run_cycle() also checks should_exit() before looking for a new entry. If the bot already holds a position and a new headline is sufficiently bearish and the price confirms the move, it sells instead of buying.
Here’s what the output looks like:

buy() and sell()apply FEE_PCT(0.1%)and SLIPPAGE_PCT(0.2%)from config.py, so PnL already reflects these costs on both entry and exit.This exit rule is intentionally simple and is not intended for production trading. Before using it, consider:
- Backtesting against historical data.
- Analyzing how different news types affect price.
- Defining an appropriate holding period.
- Adding independent stop-loss and take-profit rules.
- Considering other factors relevant to the asset and strategy.
How to Reduce False Signals in a News Trading Bot
To reduce false signals, check which filter rejects the most headlines and tune that filter first. Change one threshold at a time so you can see what each adjustment changes. The bot logs every scored headline through log_signal(), including headlines that do not result in a trade, giving you a clear record to review and tune the strategy.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
import sqlite3 import pandas as pd def rejection_breakdown(db_path="portfolio.db"): """Show which check is filtering out the most signals.""" conn = sqlite3.connect(db_path) df = pd.read_sql("SELECT reason, executed FROM signal_log", conn) total = len(df) breakdown = ( df[df["executed"] == 0]["reason"] .value_counts() .rename_axis("reason") .reset_index(name="count") ) breakdown["pct_of_total"] = (breakdown["count"] / total * 100).round(1) return total, breakdown if __name__ == "__main__": total, breakdown = rejection_breakdown() print(f"{total} headlines scored over the period\n") for _, row in breakdown.iterrows(): print(f"{row['count']:>4} {row['pct_of_total']:>5.1f}% {row['reason']}")
Here’s what the bot’s signal_log report looks like: 
CoinGecko webhooks provide a push-based alternative, with the cg.coin.info.updated event covering coin metadata changes such as contract migrations, symbol or logo updates, category changes, and public notices. Newly added public_notices can be scored with the same LLM used for news headlines, giving the bot another source of event-based signals.
For price movements, the cg.coin.price.updated webhook event triggers when a tracked coin crosses a configured price threshold or shows abnormal volatility. This webhook event is currently in private beta – join the waitlist for early access.
Conclusion
With CoinGecko API, you can build a Python trading bot that covers the full news-to-trade workflow, from ingesting headlines and scoring them with an LLM to matching them with tradeable coins, checking live price data, and recording simulated trades in a local ledger. Start with paper trading to test and refine the strategy, identify issues, and evaluate its behavior before connecting it to exchange APIs for live execution.
To combine headlines with price and OHLC data, see our guide on building an AI crypto trading bot. For more on the Crypto News endpoint, including coin filtering and pagination, fetching real-time crypto news with Python covers the details. To test a strategy against historical data, explore our crypto backtesting guide.
Ready to start building? The CoinGecko API Analyst plan unlocks the Crypto News endpoint, 500K monthly call credits, 500 RPM, and full historical depth for backtesting. If you are not ready to subscribe yet, start with a free Demo API key to explore 50+ endpoints with 10K free monthly API call credits.



