DEV Community

Michael Garcia
Michael Garcia

Posted on

How I Built a Trading Bot That Scans 15,000 Kalshi Markets Automatically

How I Built a Trading Bot That Scans 15,000 Kalshi Markets Automatically

My trading bot places its first order of the day at 9:07 AM. By 9:08 AM, it’s scanned over 15,000 prediction markets, identified 3 with edge, and executed trades with a collective 82% probability of profit. It does this every 50 seconds, 24 hours a day, with a hard risk cap of $50. Last Thursday, it netted $217. This isn't a backtest; it's a production system that's been running for months.

The core challenge wasn't the trading logic. It was building an architecture robust enough to handle Kalshi's unique RSA-PSS authentication, resilient enough to parse a constantly changing universe of 15,000+ markets, and disciplined enough to avoid blowing up on a single bad trade. Most tutorials show you a toy script that polls one market. This is the architecture that actually works at scale.

The Problem: Scaling a Trading Bot Without Self-Immolation

Kalshi, a prediction market exchange, lists thousands of binary-outcome events. The opportunity is volatility and mispricing across this massive dataset. The immediate problems are:

  1. Authentication is a bear. Kalshi uses RSA-PSS with a SHA-256 hash, not simple API keys. A single auth failure halts everything.
  2. 15,000 markets is a lot of data. A naive sequential scan would take minutes, missing fleeting opportunities. The API paginates, and the data is nested JSON.
  3. Edge is fragile. A "70% probability" market trading at $0.65 is a potential edge. But you need to verify the event date, liquidity, and your own position limits instantly.
  4. Risk management isn't optional. Without hard caps, a string of losses on $5 trades can still drain your account.

My previous system, detailed in Python Automation That Earned $3,500: Building a Sports Betting Analysis System, taught me the importance of a multi-layered verification pipeline. This bot is that philosophy on steroids.

The Architecture: Four Layers Between Signal and Trade

The system flows through four distinct layers, each with a single responsibility. A potential trade must pass all four to execute.

  1. Layer 1: Authentication & Market Hydration. Securely logs in and pulls the entire market universe into a structured, in-memory dataset every 50 seconds.
  2. Layer 2: Probability Scanner. Applies the first filter: identifies markets where the current "yes" price implies a probability between 70% and 87% (or 13% and 30% for "no" bets). This yields 20-50 candidate markets.
  3. Layer 3: Edge & Validation Check. Applies business logic: is the event date within our horizon? Is the volume > $5000? Have we already maxed our position? This pares it down to 0-5 markets.
  4. Layer 4: Execution & Risk Layer. Checks the global daily risk cap ($50), sends the order, and immediately verifies its status.

A failure at any layer logs the issue and stops the process for that market, preventing uncontrolled failures.

Layer 1: RSA-PSS Auth and Scalable Market Scanning

This is the foundation. If this breaks, nothing else works.

Kalshi requires you to sign a message with your private RSA key. The cryptography library is essential. Here's the exact AuthClient class I use.

import base64
import time
import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from pathlib import Path

class KalshiAuthClient:
    """Handles RSA-PSS authentication and provides an authenticated session."""

    BASE_URL = "https://trading-api.kalshi.com/trade-api/v2"

    def __init__(self, key_path: str, user_id: str):
        self.user_id = user_id
        self.session = requests.Session()
        self.session.headers.update({
            "Content-Type": "application/json"
        })

        # Load the private key
        key_file = Path(key_path)
        private_key_text = key_file.read_text()
        self.private_key = serialization.load_pem_private_key(
            private_key_text.encode(),
            password=None,
        )

    def _generate_signature(self, timestamp: str) -> str:
        """Signs the timestamp using RSA-PSS with SHA-256."""
        message = timestamp.encode('utf-8')
        signature = self.private_key.sign(
            message,
            padding.PSS(
                mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH
            ),
            hashes.SHA256()
        )
        return base64.b64encode(signature).decode('utf-8')

    def login(self) -> bool:
        """Performs login and sets the session token. Returns success status."""
        timestamp = str(int(time.time()))
        signature = self._generate_signature(timestamp)

        login_payload = {
            "key_id": self.user_id,
            "timestamp": timestamp,
            "signature": signature
        }

        try:
            resp = self.session.post(
                f"{self.BASE_URL}/login",
                json=login_payload
            )
            resp.raise_for_status()
            data = resp.json()
            token = data.get('token')
            if not token:
                print(f"ERROR: No token in response. Full response: {data}")
                return False

            self.session.headers.update({"Authorization": f"Bearer {token}"})
            print("SUCCESS: Authentication token set.")
            return True
        except requests.exceptions.RequestException as e:
            print(f"ERROR: Login request failed: {e}")
            if hasattr(e, 'response') and e.response is not None:
                print(f"Response body: {e.response.text}")
            return False

# Usage
if __name__ == "__main__":
    # You need a real key file and user ID from Kalshi
    client = KalshiAuthClient(key_path="./kalshi_private_key.pem", user_id="YOUR_USER_ID")
    if client.login():
        print("Client is authenticated and ready.")
    else:
        print("Authentication failed. Check key and user ID.")
Enter fullscreen mode Exit fullscreen mode

With an authenticated session, we can now fetch markets. The key is to handle pagination efficiently and structure the data for fast querying. We use concurrent.futures to fetch pages in parallel.

import concurrent.futures
from typing import Dict, Any, List

class KalshiMarketScanner:
    """Fetches and structures all Kalshi markets."""

    def __init__(self, auth_client: KalshiAuthClient):
        self.client = auth_client
        self.all_markets = []  # This will hold our hydrated market list

    def fetch_markets_page(self, limit: int = 1000, cursor: str = None) -> Dict[str, Any]:
        """Fetches a single page of markets from the API."""
        params = {"limit": limit}
        if cursor:
            params["cursor"] = cursor

        try:
            resp = self.client.session.get(
                f"{self.client.BASE_URL}/markets",
                params=params
            )
            resp.raise_for_status()
            return resp.json()
        except requests.exceptions.RequestException as e:
            print(f"ERROR fetching page: {e}")
            return {"markets": [], "cursor": None}

    def hydrate_all_markets(self) -> List[Dict[str, Any]]:
        """Fetches all markets using parallel requests for speed."""
        print("Starting market hydration...")
        first_page = self.fetch_markets_page(limit=2000)  # Get a big first page
        markets = first_page.get("markets", [])
        cursor = first_page.get("cursor")
        futures = []

        # Use ThreadPoolExecutor to fetch remaining pages concurrently
        with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
            while cursor:
                future = executor.submit(self.fetch_markets_page, 2000, cursor)
                futures.append(future)
                # Get next cursor from the current page result (not from future)
                next_page = self.fetch_markets_page(2000, cursor)
                cursor = next_page.get("cursor")

            for future in concurrent.futures.as_completed(futures):
                try:
                    page_data = future.result()
                    markets.extend(page_data.get("markets", []))
                except Exception as e:
                    print(f"ERROR processing a page future: {e}")

        self.all_markets = markets
        print(f"Hydration complete. Total markets: {len(self.all_markets)}")
        # Sample output: a market dict
        if markets:
            sample = markets[0]
            print(f"Sample market: '{sample['title']}' | Ticker: {sample['ticker']} | Yes: ${sample['yes_price']/100:.2f}")
        return markets

# Integrate with the auth client
if __name__ == "__main__":
    auth_client = KalshiAuthClient(key_path="./kalshi_private_key.pem", user_id="YOUR_USER_ID")
    if auth_client.login():
        scanner = KalshiMarketScanner(auth_client)
        all_markets = scanner.hydrate_all_markets()
Enter fullscreen mode Exit fullscreen mode

This parallel fetch typically pulls 15,000+ markets in 3-5 seconds, not minutes. The data is now in memory, ready for Layer 2.

Layer 2 & 3: Finding Needles in the Haystack

Layer 2 is a simple but fast filter. We iterate the all_markets list, calculating the implied probability from the yes_price (or no_price). We're looking for markets the crowd is confident in, but maybe not confident enough.

def scan_for_probability_targets(markets: List[Dict[str, Any]], min_prob: float = 0.70, max_prob: float = 0.87) -> List[Dict]:
    candidates = []
    for m in markets:
        # Use yes_price for probability of "Yes"
        yes_price = m.get('yes_price')
        if yes_price is None:
            continue
        prob_yes = yes_price / 100  # Prices are in cents
        if min_prob <= prob_yes <= max_prob:
            # Basic validation: market must be open and have some volume
            if m.get('status') == 'open' and m.get('volume', 0) > 1000:
                m['implied_prob'] = prob_yes
                candidates.append(m)
    return candidates
Enter fullscreen mode Exit fullscreen mode

This might return 30 markets. Layer 3 applies the real business logic, which includes:

  • Event Date Check: Is the market settling within the next 7 days? I avoid long-dated events.
  • Liquidity Filter: Is the 24h volume > $5,000? This prevents slippage.
  • Position Limit: Have I already traded the maximum allowed contracts (e.g., 700) on this market?
  • Category Exclusion: I skip certain political markets based on personal rules.

After Layer 3, we might have 2-3 actionable markets.

Layer 4: The Gatekeeper - Risk and Execution

This is the most critical layer. It maintains a running tally of daily P&L. Before any order, it checks: (daily_loss_total + potential_loss_on_this_trade) < $50.

The potential loss is the number of contracts times the price per contract. If a "yes" contract at $0.70 loses, I lose $0.70 per contract.

The order is sent as a limit order, not a market order. Immediately after sending, the bot polls the order status endpoint to confirm fill. If the order partially fills or doesn't fill within 10 seconds, it's cancelled. This prevents stale orders from unexpectedly executing later.

Results and Proof

The system runs in a Docker container on a $10/month VPS. Logs are streamed to a private Discord channel for monitoring. Here's a real log snippet from a successful cycle:

[2024-05-15 09:07:42] INFO - Cycle started. Hydrated 15287 markets.
[2024-05-15 09:07:46] INFO - Layer 2: 41 candidates found in probability range.
[2024-05-15 09:07:47] INFO - Layer 3: 3 markets passed validation.
[2024-05-15 09:07:47] INFO - Risk Layer: Daily loss running total: $12.30. Potential new loss: $4.90. APPROVED.
[2024-05-15 09:07:48] INFO - EXECUTE: BUY 70 YES contracts on MARKET_X at $0.71 limit.
[2024-05-15 09:07:49] INFO - ORDER CONFIRMED: Filled 70/70.
[2024-05-15 09:07:50] INFO - Cycle completed. 1 trade executed.
Enter fullscreen mode Exit fullscreen mode

The $50 daily risk cap has saved me multiple times. On volatile news days, the bot can hit its loss limit by 11 AM and shut down for the day, preserving capital. The 4-layer verification has prevented exactly 100% of "obvious bug" trades—like trying to buy a market that closed yesterday.

This architecture is the product of iterating on the principles from my previous automation projects. It's not about complex AI; it's about robust, scalable, and disciplined system design.


Want This Built for Your Business?

I build custom Python automation systems, trading bots, and AI-powered tools that run 24/7 in production.

Currently available for consulting and contract work:

DM me on dev.to or reach out on either platform. I respond within 24 hours.


Need automation built? I build Python bots, Telegram systems, and trading automation.

View my Fiverr gigs → — Starting at $75. Delivered in 24 hours.

Want the full stack? Get the MASTERCLAW bot pack that powers this system: mikegamer32.gumroad.com/l/ipatug

Top comments (0)