DEV Community

Samuel EF. Tinnerholm
Samuel EF. Tinnerholm

Posted on

How I Built a "Risk-Free" Arbitrage Bot for Polymarket & Kalshi

TL;DR:

I found a consistent 2-5% price spread between the two biggest prediction markets. To capture it, I had to solve the nightmare of API fragmentation. Here is the open-source bot I built to automate it.

The Opportunity: Synthetic Arbitrage

If you look closely at prediction markets, you’ll often find the same event trading at different prices on different platforms.

Kevin Hassett trading at 35¢ on Kalshi

Kevin Hassett trading at 38¢ on Polymarket

If you buy YES on Kalshi (35¢) and NO on Polymarket (63¢), your total cost is 98¢. When the event resolves, one side must pay out $1.00. That is a guaranteed $0.02 profit regardless of the outcome.

It sounds small, but if you can automate this and rotate capital weekly, that 2% compounds into a massive APY.

Cross market arbitrage explained in a graph

The Problem: API Hell

The math is easy. The engineering is hard. Polymarket is a crypto-native platform (Polygon/EVM) using a CLOB (Central Limit Order Book). Kalshi is a US-regulated exchange with a completely different REST API structure.

Trying to normalize these two data streams in real-time to find spreads is a headache, and simply not worth it.

I didn't want to write 500 lines of boilerplate code every time I wanted to test a strategy. So, I used pmxt, a unified wrapper inspired by CCXT, but for prediction markets.

The Build
Here is the core logic of the bot. You can find the full source code here.

The Build

1. Unifying the Markets

First, we need to fetch markets from both platforms and treat them as identical objects. pmxt abstracts away the messy JSON structures so we can focus on the price.

import pmxt from 'pmxtjs';

// Initialize the unified clients
this.polymarket = new pmxt.polymarket({ privateKey: config.privateKey });
this.kalshi = new pmxt.kalshi({ apiKey: config.apiKey, apiSecret: config.secret });

async fetchMarkets() {
    // We pass the "slug" for the event we want to arb (e.g., Fed Rates)
    const [polyMarkets, kalshiMarkets] = await Promise.all([
        this.polymarket.getMarketsBySlug(this.config.polymarketId),
        this.kalshi.getMarketsBySlug(this.config.kalshiId)
    ]);

    return { polyMarkets, kalshiMarkets };
}
Enter fullscreen mode Exit fullscreen mode

2. Finding the Spread

This is where the magic happens. We look for the "inversion": where Price(YES_Poly) + Price(NO_Kalshi) < $1.00.

// Inside the poll() loop
const allOpportunities = findArbitrageOpportunities(matches, minProfitCents)
    .filter(opp => {
        // Filter out dead markets with no bids/asks
        const polyYes = opp.polymarketOutcome.yesPrice;
        const kalshiNo = opp.kalshiOutcome.noPrice;

        return polyYes > 0.01 && kalshiNo > 0.01;
    })
    .sort((a, b) => b.profit - a.profit); // Rank by highest ROI

if (allOpportunities.length > 0) {
    const bestOpp = allOpportunities[0];
    await this.executeArbitrage(bestOpp);
}
Enter fullscreen mode Exit fullscreen mode

3. Execution & Rotation

Many arbitrageurs make the mistake of holding until maturity (expiration). This kills your returns. waiting 3 months to realize 2% is bad capital efficiency.

Instead, my bot uses a Rotation Strategy. It enters when the spread widens (entry point) and exits immediately when the spread closes (or when a better opportunity appears).

shouldExitPosition(opportunities) {
    if (!this.currentPosition) return false;

    const currentOpp = opportunities.find(o => o.id === this.currentPosition.id);

    // 1. Exit if the arb is gone (take profit)
    if (!currentOpp || currentOpp.profit < this.config.minProfit) return true;

    // 2. ROTATION: Exit if a BETTER opportunity exists
    const bestOpp = getBestOpportunity(opportunities);
    if (bestOpp.profit > currentOpp.profit) {
        return true;
    }

    return false;
}
Enter fullscreen mode Exit fullscreen mode

Results & Risks

The bot has successfully captured spreads ranging from 1.5% to 4.5% on high-volume events like Fed Rates and Election markets.
However, "Risk-Free" is a mathematical term, not a practical one.

Prices can move between your first and second API call. pmxt tries to minimize this latency, but it's non-zero.
You might get filled on Polymarket but stuck on Kalshi if volume dries up.
Unlike crypto DEXs, withdrawing fiat from regulated exchanges takes days, slowing down your velocity.

I built this to prove that prediction markets are mature enough for algorithmic trading. The tooling just needs to catch up.

The Library (pmxt) <-- Drop a star!

The Bot

If you find this useful, drop a star on the repo. It helps me justify spending weekends maintaining the API wrapper!

Top comments (0)