← Back to blog

Published

How to Build a Kalshi Bot with Python and Live News

A Kalshi bot should begin with the contract rules, not the headline. The bot must know exactly what resolves YES, when trading closes, and which source controls settlement before it decides whether a new article matters.

This Python example combines Kalshi's public market data with live Google News results from PrismCrawl. It derives an executable YES spread from Kalshi's bid-only order book, gathers recent evidence, and creates a reviewable paper signal. Order submission remains behind a separate dry-run boundary.

What the bot connects

Kalshi market API     -> ticker, status, rules, close time
Kalshi order book     -> YES bids and NO bids
PrismCrawl API        -> fresh Google News URLs and snippets
Research policy       -> source checks, estimate, and alert
Optional executor     -> authenticated V2 order after hard limits

Kalshi's market-data quick start says its public production market endpoints can be read without authentication. Private portfolio and order endpoints require signed requests. That makes the public API a good starting point for research and paper trading.

Benefits of a Kalshi news bot

  • Rules-first research: The market response includes settlement rules and time fields that can travel with every signal.
  • Event-specific monitoring: Series and market tickers give the scheduler stable identifiers instead of relying on title matching every cycle.
  • Visible execution costs: YES and NO bids expose the spread that a midpoint-only strategy can hide.
  • Auditable evidence: PrismCrawl returns source URLs and request IDs that can be stored beside the market snapshot.
  • Controlled rollout: The same research loop can run as a logger, alerting service, or paper trader before an executor is allowed to exist.

Step 1: Read one approved Kalshi market

Use a ticker selected from your own allowlist. The Get Markets reference supports filters such as series_ticker and status, but fuzzy title matching should not decide what your bot is allowed to trade.

from dataclasses import dataclass
from decimal import Decimal

import requests

KALSHI_URL = "https://external-api.kalshi.com/trade-api/v2"


@dataclass(frozen=True)
class KalshiSnapshot:
    ticker: str
    title: str
    rules: str
    status: str
    close_time: str
    best_yes_bid: Decimal
    best_yes_ask: Decimal

    @property
    def midpoint(self) -> Decimal:
        return (self.best_yes_bid + self.best_yes_ask) / Decimal("2")


def read_kalshi_snapshot(ticker: str) -> KalshiSnapshot:
    market_response = requests.get(f"{KALSHI_URL}/markets/{ticker}", timeout=20)
    market_response.raise_for_status()
    market = market_response.json()["market"]

    book_response = requests.get(f"{KALSHI_URL}/markets/{ticker}/orderbook", timeout=20)
    book_response.raise_for_status()
    book = book_response.json()["orderbook_fp"]

    yes_bids = [Decimal(price) for price, _count in book["yes_dollars"]]
    no_bids = [Decimal(price) for price, _count in book["no_dollars"]]
    if not yes_bids or not no_bids:
        raise ValueError("The selected market does not have a two-sided book")

    best_yes_bid = max(yes_bids)
    best_yes_ask = Decimal("1") - max(no_bids)

    return KalshiSnapshot(
        ticker=market["ticker"],
        title=market["title"],
        rules="\n".join(
            part for part in [market.get("rules_primary"), market.get("rules_secondary")] if part
        ),
        status=market["status"],
        close_time=market["close_time"],
        best_yes_bid=best_yes_bid,
        best_yes_ask=best_yes_ask,
    )

Kalshi's order-book guide explains the book shape: the API returns bids for YES and NO rather than a separate ask array. A NO bid at 35 cents implies a YES ask at 65 cents. Contract counts and prices are fixed-point strings, so the example uses Decimal instead of binary floating-point arithmetic.

Reject paused, closed, or settled markets before doing any research. Also reject a negative or implausibly wide spread. A stale or one-sided book is a reason to wait, not a reason to invent a price.

Step 2: Turn the settlement rules into searches

The short title is useful for discovery, but the rule language decides relevance. Build a small query set with named institutions and the market's authoritative source.

from dataclasses import dataclass


@dataclass(frozen=True)
class ResearchPlan:
    ticker: str
    queries: tuple[str, ...]
    primary_domains: frozenset[str]


plan = ResearchPlan(
    ticker="YOUR_APPROVED_TICKER",
    queries=(
        '"exact event phrase" official announcement',
        'site:example.gov "exact event phrase"',
        '"exact event phrase" latest update',
    ),
    primary_domains=frozenset({"example.gov"}),
)

The values are intentionally illustrative. Replace them only after reading the actual market rules. A query about an announcement is not equivalent to a query about when a policy takes effect.

Step 3: Retrieve live news results through PrismCrawl

PrismCrawl can select Google's News tab with udm: 12 and restrict the result window with tbs. Each successful query below consumes one credit. A failed search does not consume a credit.

import os
from urllib.parse import urlsplit

PRISMCRAWL_API_KEY = os.environ["PRISMCRAWL_API_KEY"]
PRISMCRAWL_URL = "https://api.prismcrawl.com/v1/google/search"


def search_prismcrawl(query: str) -> list[dict]:
    response = requests.post(
        PRISMCRAWL_URL,
        headers={"x-api-key": PRISMCRAWL_API_KEY},
        json={
            "query": query,
            "gl": "us",
            "hl": "en-US",
            "udm": 12,
            "tbs": "qdr:d",
        },
        timeout=60,
    )
    response.raise_for_status()
    body = response.json()
    if not body["success"]:
        raise RuntimeError(body["error"]["message"])

    return [
        {
            "request_id": body["request_id"],
            "rank": result["rank"],
            "title": result["title"],
            "url": result["url"],
            "domain": (urlsplit(result["url"]).hostname or "").removeprefix("www."),
            "snippet": result.get("snippet"),
        }
        for result in body["data"]["content"]["results"]
        if result.get("url")
    ]


def collect_evidence(plan: ResearchPlan) -> list[dict]:
    by_url: dict[str, dict] = {}
    for query in plan.queries:
        for result in search_prismcrawl(query):
            clean_url = result["url"].split("#", 1)[0].rstrip("/")
            by_url.setdefault(clean_url, {**result, "url": clean_url})
    return list(by_url.values())

See the PrismCrawl API reference for the full request fields and normalized response. If regional interpretation matters, set the country, language, or location deliberately and save those parameters with the signal.

Step 4: Require primary evidence and an executable edge

An alert should fail closed when evidence is thin. The example requires an open market, a bounded spread, at least one approved primary domain, two independent domains, and an illustrative probability difference.

def build_kalshi_alert(
    snapshot: KalshiSnapshot,
    research_probability: Decimal,
    evidence: list[dict],
    primary_domains: frozenset[str],
) -> dict | None:
    if snapshot.status != "open":
        return None
    if not Decimal("0") <= research_probability <= Decimal("1"):
        raise ValueError("research_probability must be between 0 and 1")

    spread = snapshot.best_yes_ask - snapshot.best_yes_bid
    domains = {item["domain"] for item in evidence}
    has_primary_source = bool(domains & primary_domains)
    difference = research_probability - snapshot.midpoint

    if spread > Decimal("0.08") or len(domains) < 2 or not has_primary_source:
        return None
    if abs(difference) < Decimal("0.10"):
        return None

    return {
        "action": "human_review",
        "ticker": snapshot.ticker,
        "market_title": snapshot.title,
        "close_time": snapshot.close_time,
        "best_yes_bid": str(snapshot.best_yes_bid),
        "best_yes_ask": str(snapshot.best_yes_ask),
        "research_probability": str(research_probability),
        "source_urls": [item["url"] for item in evidence[:5]],
        "prismcrawl_request_ids": sorted({item["request_id"] for item in evidence}),
    }

The probability input must come from a documented method that reads verified source material. Search rank, result count, and snippet sentiment are not probabilities. Test positive, negative, ambiguous, late, duplicated, and corrected stories before trusting the classifier.

Step 5: Build an order intent without sending it

Kalshi's current V2 order endpoint uses one YES-side book: bid buys YES and ask sells YES. The function below creates a dry-run intent and enforces a small set of local limits. It does not sign or transmit anything.

from uuid import uuid4


def build_dry_run_yes_bid(
    alert: dict,
    limit_price: Decimal,
    contracts: Decimal,
) -> dict:
    if alert["action"] != "human_review":
        raise ValueError("Only reviewed alerts may create an order intent")
    if contracts <= 0 or contracts > Decimal("10"):
        raise ValueError("Contract count exceeds the illustrative local limit")
    if limit_price <= 0 or limit_price >= 1:
        raise ValueError("Limit price must be between 0 and 1")

    return {
        "dry_run": True,
        "endpoint": "/portfolio/events/orders",
        "payload": {
            "ticker": alert["ticker"],
            "client_order_id": str(uuid4()),
            "side": "bid",
            "count": f"{contracts:.2f}",
            "price": f"{limit_price:.4f}",
            "time_in_force": "immediate_or_cancel",
            "self_trade_prevention_type": "taker_at_cross",
            "post_only": False,
            "cancel_order_on_pause": True,
            "reduce_only": False,
            "subaccount": 0,
            "exchange_index": 0,
        },
    }

Review Kalshi's Create Order V2 documentation before implementing authentication. Kalshi also documents order groups, which can cap matched contracts over a rolling window. Exchange controls should supplement your own maximum exposure, stale-data cutoff, duplicate-order protection, and kill switch.

Where this bot is useful

Kalshi markets tied to scheduled public releases are natural candidates because the contract can point to a specific measurement or announcement. Economic releases, agency decisions, weather observations, and election administration events all benefit from a rules-aware monitor.

Use the authoritative data feed directly when it exists. A news search can discover commentary or an official release, but it should not replace the agency series, exchange feed, or measurement source named by the contract. The news layer is most valuable for context, change detection, and finding the primary document quickly.

Run the system as a logger first. Then backtest with the information that was actually available at each historical timestamp, paper trade against contemporaneous bid and ask prices, and review failures. Only a measured process should be considered for live execution, subject to Kalshi's current eligibility rules and your legal and compliance obligations.

Create a PrismCrawl account to try the news request, or compare this design with the dedicated Polymarket bot guide.

Frequently asked questions

Can PrismCrawl submit a Kalshi order?

No. PrismCrawl supplies live Google and Bing search results. Your application reads Kalshi market data and, only if you enable a separately authenticated executor, submits orders through Kalshi's official API.

Does the Kalshi order book return asks?

Kalshi's public order-book response contains YES bids and NO bids. A NO bid at a given price implies the corresponding YES ask at one minus that price.

What should a Kalshi bot verify before creating a signal?

Verify the exact ticker, primary and secondary rules, close and expiration times, market status, executable spread, source publication times, and whether the evidence addresses the settlement condition.