Posted on

Posted on

Author

Author

QalbeHabib

QalbeHabib

Article

Build a Cross-Venue Prediction Market Arbitrage Scanner in 30 Minutes

Build a Cross-Venue Prediction Market Arbitrage Scanner in 30 Minutes

Build a Cross-Venue Prediction Market Arbitrage Scanner in 30 Minutes

The same event is priced differently across Polymarket, Kalshi, and Limitless every day. Most of those spreads are traps. Here's how to find the real ones — with working Python code against the Assymetrix Data API.

Prediction markets are one of the few asset classes where the same underlying event trades simultaneously on multiple venues — at different prices, with different rules, in different liquidity pools, to different trader populations. That structural fragmentation is also a structural opportunity. If you can identify when "the Fed cuts in June" is priced at 41% on one venue and 47% on another, and you can confirm the contracts are actually about the same outcome, you have a candidate arbitrage.

Most of those candidates are traps. A few are real.

This post walks through building a cross-venue arbitrage scanner against the Assymetrix Data API — the full pipeline from "pull live prices" through "filter for real opportunities" to "rank by net-of-fees profit potential." Working Python, runnable in an afternoon, designed to find opportunities you can actually trade, not the phantom spreads that show up when you compare titles instead of structure.

If you read BB#2 on backtesting, this is the natural sequel: BB#2 was about testing strategies against historical data. This is about finding what's tradeable right now.

Why Cross-Venue Arbitrage Exists in the First Place

Three structural reasons:

Different trader populations. Polymarket attracts crypto-native traders; Kalshi attracts US retail and institutions with proper KYC; Limitless serves a different liquidity profile again. Each population has different information, different biases, and different risk appetites. The same event gets priced differently because the people pricing it are different.

Different liquidity profiles. A market with $5M in volume on Polymarket might have $50K on Kalshi. Thin liquidity creates price gaps. Even when both venues "agree" on the underlying probability, the order books look completely different.

Different fee structures and capital requirements. Polymarket trades USDC on-chain (gas costs, slippage on AMM-style execution depending on market). Kalshi trades USD off-chain (taker fees, no gas). The cost of being on a venue affects the price you'll see there.

Different resolution rules — and this is the one that creates most of the traps. Two markets with nearly identical titles may resolve on completely different criteria. We covered this in BB#1's Finding #1: a significant share of "matching" markets across platforms have at least one material difference in how they resolve.

The arbitrage opportunity is real. The challenge is separating real spreads from structural mismatches dressed up as spreads.

The Naive Approach (What Doesn't Work)

A first instinct is to pull market prices from each platform, find matching titles, and look for gaps. This fails immediately.

import requests

# Pull live prices from three venues — DON'T DO THIS
pm_response = requests.get("https://gamma-api.polymarket.com/markets")
kx_response = requests.get("https://api.kalshi.com/v2/markets")
lm_response = requests.get("https://api.limitless.exchange/markets")

# Match by title similarity? Hope you're feeling lucky.
# Polymarket: "Fed cut by July 2026?"
# Kalshi:     "Federal Reserve rate decision July 2026"
# Limitless:  "Fed funds rate cut Q2 2026"
#
# These look like the same event. They aren't.
# - Polymarket resolves on FOMC announcement date
# - Kalshi resolves on official rate decision publication
# - Limitless resolves on quarterly average rate change
#
# Title-matching arbitrage is phantom arbitrage. You're
# comparing three different bets, not one mispriced one.
import requests

# Pull live prices from three venues — DON'T DO THIS
pm_response = requests.get("https://gamma-api.polymarket.com/markets")
kx_response = requests.get("https://api.kalshi.com/v2/markets")
lm_response = requests.get("https://api.limitless.exchange/markets")

# Match by title similarity? Hope you're feeling lucky.
# Polymarket: "Fed cut by July 2026?"
# Kalshi:     "Federal Reserve rate decision July 2026"
# Limitless:  "Fed funds rate cut Q2 2026"
#
# These look like the same event. They aren't.
# - Polymarket resolves on FOMC announcement date
# - Kalshi resolves on official rate decision publication
# - Limitless resolves on quarterly average rate change
#
# Title-matching arbitrage is phantom arbitrage. You're
# comparing three different bets, not one mispriced one.
import requests

# Pull live prices from three venues — DON'T DO THIS
pm_response = requests.get("https://gamma-api.polymarket.com/markets")
kx_response = requests.get("https://api.kalshi.com/v2/markets")
lm_response = requests.get("https://api.limitless.exchange/markets")

# Match by title similarity? Hope you're feeling lucky.
# Polymarket: "Fed cut by July 2026?"
# Kalshi:     "Federal Reserve rate decision July 2026"
# Limitless:  "Fed funds rate cut Q2 2026"
#
# These look like the same event. They aren't.
# - Polymarket resolves on FOMC announcement date
# - Kalshi resolves on official rate decision publication
# - Limitless resolves on quarterly average rate change
#
# Title-matching arbitrage is phantom arbitrage. You're
# comparing three different bets, not one mispriced one.

The structural problem isn't that you can't pull the data — every platform has a public API. The problem is that without normalized resolution metadata, you can't tell whether the price difference reflects an inefficiency you can trade or two contracts that just happen to share a headline.

This is what the Assymetrix canonical schema does. Every market is mapped to a canonical event with explicit resolution criteria, and matched markets across venues carry a resolution_compatible flag that tells you whether the comparison is structurally valid.

That flag is the entire foundation of cross-venue arbitrage. Without it, you'll spend three weeks building infrastructure and another three weeks discovering that 80% of your "opportunities" weren't opportunities at all.

The 30-Minute Scanner

Here's the complete scanner, built against the Assymetrix Data API.

Step 1: Setup


# pip install assymetrix
from assymetrix import Client
import pandas as pd

ax = Client(api_key="your_key")  # data.assymetrix.com

# Platform-specific fees and slippage assumptions
# These change — verify against current platform docs
TAKER_FEES = {
    "polymarket": 0.000,    # 0% taker on most markets
    "kalshi":     0.020,    # ~2% on outcomes < $0.50
    "limitless":  0.005,    # ~0.5% taker
}

# Slippage estimates by liquidity tier (these are rough)
def estimate_slippage(orderbook_depth_usd, position_size_usd):
    """Conservative slippage estimate as a function of size vs depth."""
    if orderbook_depth_usd < 5_000:
        return None # signal: untradeable 
    if position_size_usd / orderbook_depth_usd > 0.10:
        return 0.03  # Eating >10% of book, expect impact
    if position_size_usd / orderbook_depth_usd > 0.03:
        return 0.01
    return 0.003     # Below 3% of book — minimal impact

MIN_VOLUME_USD = 50_000     # Skip markets below this
MIN_SPREAD_PCT = 0.04        # Don't bother below 4% gross
POSITION_SIZE_USD = 1_000    # Per-leg position sizing
# pip install assymetrix
from assymetrix import Client
import pandas as pd

ax = Client(api_key="your_key")  # data.assymetrix.com

# Platform-specific fees and slippage assumptions
# These change — verify against current platform docs
TAKER_FEES = {
    "polymarket": 0.000,    # 0% taker on most markets
    "kalshi":     0.020,    # ~2% on outcomes < $0.50
    "limitless":  0.005,    # ~0.5% taker
}

# Slippage estimates by liquidity tier (these are rough)
def estimate_slippage(orderbook_depth_usd, position_size_usd):
    """Conservative slippage estimate as a function of size vs depth."""
    if orderbook_depth_usd < 5_000:
        return None # signal: untradeable 
    if position_size_usd / orderbook_depth_usd > 0.10:
        return 0.03  # Eating >10% of book, expect impact
    if position_size_usd / orderbook_depth_usd > 0.03:
        return 0.01
    return 0.003     # Below 3% of book — minimal impact

MIN_VOLUME_USD = 50_000     # Skip markets below this
MIN_SPREAD_PCT = 0.04        # Don't bother below 4% gross
POSITION_SIZE_USD = 1_000    # Per-leg position sizing
# pip install assymetrix
from assymetrix import Client
import pandas as pd

ax = Client(api_key="your_key")  # data.assymetrix.com

# Platform-specific fees and slippage assumptions
# These change — verify against current platform docs
TAKER_FEES = {
    "polymarket": 0.000,    # 0% taker on most markets
    "kalshi":     0.020,    # ~2% on outcomes < $0.50
    "limitless":  0.005,    # ~0.5% taker
}

# Slippage estimates by liquidity tier (these are rough)
def estimate_slippage(orderbook_depth_usd, position_size_usd):
    """Conservative slippage estimate as a function of size vs depth."""
    if orderbook_depth_usd < 5_000:
        return None # signal: untradeable 
    if position_size_usd / orderbook_depth_usd > 0.10:
        return 0.03  # Eating >10% of book, expect impact
    if position_size_usd / orderbook_depth_usd > 0.03:
        return 0.01
    return 0.003     # Below 3% of book — minimal impact

MIN_VOLUME_USD = 50_000     # Skip markets below this
MIN_SPREAD_PCT = 0.04        # Don't bother below 4% gross
POSITION_SIZE_USD = 1_000    # Per-leg position sizing

The fee and slippage assumptions matter enormously and they're the part most arbitrage tutorials hand-wave. Different markets have different fee tiers, different liquidity profiles, and different execution slippage. Get these wrong and a "5% spread" turns into a 1% loss after costs.

Step 2: Pull Cross-Venue Divergences


# The Assymetrix API does the structural matching for you.
# resolution_compatible=True is the critical filter.
divergences = ax.markets.get_divergences(
    platforms=["polymarket", "kalshi", "limitless"],
    min_spread=MIN_SPREAD_PCT,
    min_volume=MIN_VOLUME_USD,
    resolution_compatible=True,   # NON-NEGOTIABLE
    include_orderbook=True,        # Need depth for slippage calc
    active_only=True,
)

print(f"Candidate divergences: {len(divergences)}")
# The Assymetrix API does the structural matching for you.
# resolution_compatible=True is the critical filter.
divergences = ax.markets.get_divergences(
    platforms=["polymarket", "kalshi", "limitless"],
    min_spread=MIN_SPREAD_PCT,
    min_volume=MIN_VOLUME_USD,
    resolution_compatible=True,   # NON-NEGOTIABLE
    include_orderbook=True,        # Need depth for slippage calc
    active_only=True,
)

print(f"Candidate divergences: {len(divergences)}")
# The Assymetrix API does the structural matching for you.
# resolution_compatible=True is the critical filter.
divergences = ax.markets.get_divergences(
    platforms=["polymarket", "kalshi", "limitless"],
    min_spread=MIN_SPREAD_PCT,
    min_volume=MIN_VOLUME_USD,
    resolution_compatible=True,   # NON-NEGOTIABLE
    include_orderbook=True,        # Need depth for slippage calc
    active_only=True,
)

print(f"Candidate divergences: {len(divergences)}")

A typical response from this query might return 10-40 candidates depending on market conditions, time of day, and the kinds of events currently trading. The resolution_compatible=True filter cuts the noise dramatically — without it, we typically see 5-10x more candidates, the overwhelming majority of which are structural mismatches.

Step 3: Calculate Net Profit Potential

This is where most naive arbitrage strategies die. The gross spread is the headline number. The net spread — after fees, slippage, and execution costs — is what you actually realize.


def evaluate_opportunity(div, position_size=POSITION_SIZE_USD):
    """
    For each cross-venue divergence, calculate the realistic
    net-of-fees profit on a paired position.

    Strategy: buy the cheaper YES, buy the cheaper NO on the
    other venue. If priced at p_cheap and (1 - p_expensive),
    the combined cost is below $1, locking in the spread minus
    costs.
    """
    # Find cheapest YES and cheapest NO across the venues
    yes_prices = {p.name: (p.yes_price, p.yes_book_depth)
                  for p in div.platforms}
    no_prices = {p.name: (p.no_price, p.no_book_depth)
     for p in div.platforms}
    cheapest_yes_venue = min(yes_prices, key=lambda v: yes_prices[v][0])
    cheapest_no_venue  = min(no_prices,  key=lambda v: no_prices[v][0])

    # If both legs would execute on the same venue, no arb
    if cheapest_yes_venue == cheapest_no_venue:
        return None

    yes_price, yes_depth = yes_prices[cheapest_yes_venue]
    no_price,  no_depth  = no_prices[cheapest_no_venue]

    # Gross spread — the headline number
    gross_combined_cost = yes_price + no_price
    gross_spread = 1.0 - gross_combined_cost

    if gross_spread < MIN_SPREAD_PCT:
        return None

    # Fees on each leg
    yes_fee = TAKER_FEES[cheapest_yes_venue] * position_size
    no_fee  = TAKER_FEES[cheapest_no_venue]  * position_size

    # Slippage estimate per leg
    yes_slip = estimate_slippage(yes_depth, position_size) * position_size
    no_slip  = estimate_slippage(no_depth,  position_size) * position_size
    if yes_slip is None or no_slip is None:
    	return None  # drop illiquid legs immediately

    # Realized profit = (gross spread on position size) - fees - slippage
  	yes_shares = position_size / yes_price
no_shares = position_size / no_price
gross_profit_usd = min(yes_shares, no_shares) - (position_size * 2)
net_profit_usd = gross_profit_usd - yes_fee - no_fee - yes_slip - no_slip
	net_spread_pct = net_profit_usd / (2 * position_size) 

    return {
        "event":         div.canonical_event_title,
        "yes_venue":     cheapest_yes_venue,
        "yes_price":     yes_price,
        "no_venue":      cheapest_no_venue,
        "no_price":      no_price,
        "gross_spread":  gross_spread,
        "fees_total":    yes_fee + no_fee,
        "slippage_est":  yes_slip + no_slip,
        "net_profit":    net_profit_usd,
        "net_spread":    net_spread_pct,
        "min_liquidity": min(yes_depth, no_depth),
    }

opportunities = [evaluate_opportunity(d) for d in divergences]
opportunities = [o for o in opportunities if o is not None]
opportunities.sort(key=lambda o: o["net_spread"], reverse=True)
def evaluate_opportunity(div, position_size=POSITION_SIZE_USD):
    """
    For each cross-venue divergence, calculate the realistic
    net-of-fees profit on a paired position.

    Strategy: buy the cheaper YES, buy the cheaper NO on the
    other venue. If priced at p_cheap and (1 - p_expensive),
    the combined cost is below $1, locking in the spread minus
    costs.
    """
    # Find cheapest YES and cheapest NO across the venues
    yes_prices = {p.name: (p.yes_price, p.yes_book_depth)
                  for p in div.platforms}
    no_prices = {p.name: (p.no_price, p.no_book_depth)
     for p in div.platforms}
    cheapest_yes_venue = min(yes_prices, key=lambda v: yes_prices[v][0])
    cheapest_no_venue  = min(no_prices,  key=lambda v: no_prices[v][0])

    # If both legs would execute on the same venue, no arb
    if cheapest_yes_venue == cheapest_no_venue:
        return None

    yes_price, yes_depth = yes_prices[cheapest_yes_venue]
    no_price,  no_depth  = no_prices[cheapest_no_venue]

    # Gross spread — the headline number
    gross_combined_cost = yes_price + no_price
    gross_spread = 1.0 - gross_combined_cost

    if gross_spread < MIN_SPREAD_PCT:
        return None

    # Fees on each leg
    yes_fee = TAKER_FEES[cheapest_yes_venue] * position_size
    no_fee  = TAKER_FEES[cheapest_no_venue]  * position_size

    # Slippage estimate per leg
    yes_slip = estimate_slippage(yes_depth, position_size) * position_size
    no_slip  = estimate_slippage(no_depth,  position_size) * position_size
    if yes_slip is None or no_slip is None:
    	return None  # drop illiquid legs immediately

    # Realized profit = (gross spread on position size) - fees - slippage
  	yes_shares = position_size / yes_price
no_shares = position_size / no_price
gross_profit_usd = min(yes_shares, no_shares) - (position_size * 2)
net_profit_usd = gross_profit_usd - yes_fee - no_fee - yes_slip - no_slip
	net_spread_pct = net_profit_usd / (2 * position_size) 

    return {
        "event":         div.canonical_event_title,
        "yes_venue":     cheapest_yes_venue,
        "yes_price":     yes_price,
        "no_venue":      cheapest_no_venue,
        "no_price":      no_price,
        "gross_spread":  gross_spread,
        "fees_total":    yes_fee + no_fee,
        "slippage_est":  yes_slip + no_slip,
        "net_profit":    net_profit_usd,
        "net_spread":    net_spread_pct,
        "min_liquidity": min(yes_depth, no_depth),
    }

opportunities = [evaluate_opportunity(d) for d in divergences]
opportunities = [o for o in opportunities if o is not None]
opportunities.sort(key=lambda o: o["net_spread"], reverse=True)
def evaluate_opportunity(div, position_size=POSITION_SIZE_USD):
    """
    For each cross-venue divergence, calculate the realistic
    net-of-fees profit on a paired position.

    Strategy: buy the cheaper YES, buy the cheaper NO on the
    other venue. If priced at p_cheap and (1 - p_expensive),
    the combined cost is below $1, locking in the spread minus
    costs.
    """
    # Find cheapest YES and cheapest NO across the venues
    yes_prices = {p.name: (p.yes_price, p.yes_book_depth)
                  for p in div.platforms}
    no_prices = {p.name: (p.no_price, p.no_book_depth)
     for p in div.platforms}
    cheapest_yes_venue = min(yes_prices, key=lambda v: yes_prices[v][0])
    cheapest_no_venue  = min(no_prices,  key=lambda v: no_prices[v][0])

    # If both legs would execute on the same venue, no arb
    if cheapest_yes_venue == cheapest_no_venue:
        return None

    yes_price, yes_depth = yes_prices[cheapest_yes_venue]
    no_price,  no_depth  = no_prices[cheapest_no_venue]

    # Gross spread — the headline number
    gross_combined_cost = yes_price + no_price
    gross_spread = 1.0 - gross_combined_cost

    if gross_spread < MIN_SPREAD_PCT:
        return None

    # Fees on each leg
    yes_fee = TAKER_FEES[cheapest_yes_venue] * position_size
    no_fee  = TAKER_FEES[cheapest_no_venue]  * position_size

    # Slippage estimate per leg
    yes_slip = estimate_slippage(yes_depth, position_size) * position_size
    no_slip  = estimate_slippage(no_depth,  position_size) * position_size
    if yes_slip is None or no_slip is None:
    	return None  # drop illiquid legs immediately

    # Realized profit = (gross spread on position size) - fees - slippage
  	yes_shares = position_size / yes_price
no_shares = position_size / no_price
gross_profit_usd = min(yes_shares, no_shares) - (position_size * 2)
net_profit_usd = gross_profit_usd - yes_fee - no_fee - yes_slip - no_slip
	net_spread_pct = net_profit_usd / (2 * position_size) 

    return {
        "event":         div.canonical_event_title,
        "yes_venue":     cheapest_yes_venue,
        "yes_price":     yes_price,
        "no_venue":      cheapest_no_venue,
        "no_price":      no_price,
        "gross_spread":  gross_spread,
        "fees_total":    yes_fee + no_fee,
        "slippage_est":  yes_slip + no_slip,
        "net_profit":    net_profit_usd,
        "net_spread":    net_spread_pct,
        "min_liquidity": min(yes_depth, no_depth),
    }

opportunities = [evaluate_opportunity(d) for d in divergences]
opportunities = [o for o in opportunities if o is not None]
opportunities.sort(key=lambda o: o["net_spread"], reverse=True)

The evaluate_opportunity function is doing the work most tutorials skip. The gross spread between venues might look like 6%. After 2% fees on the Kalshi leg, 1% slippage on the Limitless leg, and 0.5% in other frictions, the realized spread might be 2.5%. Still tradeable. But the difference between "6% gross" and "2.5% net" is the difference between thinking you found gold and actually finding it.

Step 4: Format and Surface the Results


print(f"\nFound {len(opportunities)} actionable opportunities\n")
print(f"{'Event':<40} {'Net %':>7} {'Net $':>8} {'Min Liq':>9}")
print("-" * 70)

for opp in opportunities[:10]:
    print(
        f"{opp['event'][:38]:<40} "
        f"{opp['net_spread']*100:>6.2f}% "
        f"${opp['net_profit']:>6.0f} "
        f"${opp['min_liquidity']:>7.0f}"
    )
print(f"\nFound {len(opportunities)} actionable opportunities\n")
print(f"{'Event':<40} {'Net %':>7} {'Net $':>8} {'Min Liq':>9}")
print("-" * 70)

for opp in opportunities[:10]:
    print(
        f"{opp['event'][:38]:<40} "
        f"{opp['net_spread']*100:>6.2f}% "
        f"${opp['net_profit']:>6.0f} "
        f"${opp['min_liquidity']:>7.0f}"
    )
print(f"\nFound {len(opportunities)} actionable opportunities\n")
print(f"{'Event':<40} {'Net %':>7} {'Net $':>8} {'Min Liq':>9}")
print("-" * 70)

for opp in opportunities[:10]:
    print(
        f"{opp['event'][:38]:<40} "
        f"{opp['net_spread']*100:>6.2f}% "
        f"${opp['net_profit']:>6.0f} "
        f"${opp['min_liquidity']:>7.0f}"
    )

Sample output:


Found 7 actionable opportunities

Event                                      Net %    Net $   Min Liq
----------------------------------------------------------------------
Fed cuts rates at June FOMC               3.84%      $38  $145000
Iran ceasefire continues through July    2.91%      $29   $87000
US recession in Q3 2026                  2.46%      $25  $312000
NFL Chiefs win division                  2.18%      $22   $52000
Trump approval > 45% end June            1.74%      $17   $98000
Bitcoin > $80K June close                1.31%      $13  $204000
Eurozone inflation > 2% June             0.84%       $8   $71000
Found 7 actionable opportunities

Event                                      Net %    Net $   Min Liq
----------------------------------------------------------------------
Fed cuts rates at June FOMC               3.84%      $38  $145000
Iran ceasefire continues through July    2.91%      $29   $87000
US recession in Q3 2026                  2.46%      $25  $312000
NFL Chiefs win division                  2.18%      $22   $52000
Trump approval > 45% end June            1.74%      $17   $98000
Bitcoin > $80K June close                1.31%      $13  $204000
Eurozone inflation > 2% June             0.84%       $8   $71000
Found 7 actionable opportunities

Event                                      Net %    Net $   Min Liq
----------------------------------------------------------------------
Fed cuts rates at June FOMC               3.84%      $38  $145000
Iran ceasefire continues through July    2.91%      $29   $87000
US recession in Q3 2026                  2.46%      $25  $312000
NFL Chiefs win division                  2.18%      $22   $52000
Trump approval > 45% end June            1.74%      $17   $98000
Bitcoin > $80K June close                1.31%      $13  $204000
Eurozone inflation > 2% June             0.84%       $8   $71000

Seven opportunities from this scan, ranked by net spread. The top three offer net spreads of 2.5-4% after realistic frictions on $1,000 positions. The bottom of the list (sub-1% net) is borderline — execution risk eats most of the profit and these positions tie up capital for days or weeks until resolution.

Step 5: Run It on a Schedule


import time
import schedule
alerted = {}	# key -> timestamp of first alert ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes 
def scan():
    timestamp = pd.Timestamp.now().isoformat()
    print(f"\n=== Scan at {timestamp} ===")

    divergences = ax.markets.get_divergences(
        platforms=["polymarket", "kalshi", "limitless"],
        min_spread=MIN_SPREAD_PCT,
        min_volume=MIN_VOLUME_USD,
        resolution_compatible=True,
        include_orderbook=True,
        active_only=True,
    )

    opportunities = [evaluate_opportunity(d) for d in divergences]
    opportunities = [o for o in opportunities if o is not None]
    opportunities.sort(key=lambda o: o["net_spread"], reverse=True)
    

    # Alert on any opportunity above your threshold
    now = time.time() 
    for opp in opportunities:
        if opp["net_spread"] > 0.025:  # 2.5%+ net spread
            key = (opp["event"], opp["yes_venue"], opp["no_venue"]) 
     last_alerted =  alerted.get(key, 0) 
     if now - last_alerted > ALERT_COOLDOWN_SECONDS: 
          send_alert(opp) 
                 alerted[key] = now 

# Scan every 5 minutes during active trading hours
schedule.every(5).minutes.do(scan)

while True:
    schedule.run_pending()
    time.sleep(1)
import time
import schedule
alerted = {}	# key -> timestamp of first alert ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes 
def scan():
    timestamp = pd.Timestamp.now().isoformat()
    print(f"\n=== Scan at {timestamp} ===")

    divergences = ax.markets.get_divergences(
        platforms=["polymarket", "kalshi", "limitless"],
        min_spread=MIN_SPREAD_PCT,
        min_volume=MIN_VOLUME_USD,
        resolution_compatible=True,
        include_orderbook=True,
        active_only=True,
    )

    opportunities = [evaluate_opportunity(d) for d in divergences]
    opportunities = [o for o in opportunities if o is not None]
    opportunities.sort(key=lambda o: o["net_spread"], reverse=True)
    

    # Alert on any opportunity above your threshold
    now = time.time() 
    for opp in opportunities:
        if opp["net_spread"] > 0.025:  # 2.5%+ net spread
            key = (opp["event"], opp["yes_venue"], opp["no_venue"]) 
     last_alerted =  alerted.get(key, 0) 
     if now - last_alerted > ALERT_COOLDOWN_SECONDS: 
          send_alert(opp) 
                 alerted[key] = now 

# Scan every 5 minutes during active trading hours
schedule.every(5).minutes.do(scan)

while True:
    schedule.run_pending()
    time.sleep(1)
import time
import schedule
alerted = {}	# key -> timestamp of first alert ALERT_COOLDOWN_SECONDS = 1800 # 30 minutes 
def scan():
    timestamp = pd.Timestamp.now().isoformat()
    print(f"\n=== Scan at {timestamp} ===")

    divergences = ax.markets.get_divergences(
        platforms=["polymarket", "kalshi", "limitless"],
        min_spread=MIN_SPREAD_PCT,
        min_volume=MIN_VOLUME_USD,
        resolution_compatible=True,
        include_orderbook=True,
        active_only=True,
    )

    opportunities = [evaluate_opportunity(d) for d in divergences]
    opportunities = [o for o in opportunities if o is not None]
    opportunities.sort(key=lambda o: o["net_spread"], reverse=True)
    

    # Alert on any opportunity above your threshold
    now = time.time() 
    for opp in opportunities:
        if opp["net_spread"] > 0.025:  # 2.5%+ net spread
            key = (opp["event"], opp["yes_venue"], opp["no_venue"]) 
     last_alerted =  alerted.get(key, 0) 
     if now - last_alerted > ALERT_COOLDOWN_SECONDS: 
          send_alert(opp) 
                 alerted[key] = now 

# Scan every 5 minutes during active trading hours
schedule.every(5).minutes.do(scan)

while True:
    schedule.run_pending()
    time.sleep(1)

That's the entire scanner. About 100 lines of code, the core logic in 30-40. The expensive parts — pulling normalized cross-venue prices, matching events by structure rather than title, validating resolution compatibility, providing real-time order book depth — are handled by the API. Without that infrastructure layer, this same scanner would take two to three weeks to build and would need ongoing maintenance every time a platform changes its API.

Reading the Output: Real Opportunities vs. Traps

Three categories of result you'll see in any cross-venue scan, with how to tell them apart.

Real opportunity. Spread of 2-5% net after fees and slippage. Liquidity above $50K on both legs. Resolution compatible. Reasonable time to resolution (weeks, not months — capital cost matters). These exist but they're rare and they don't last long once anyone with serious capital starts trading them.

Liquidity trap. Headline spread looks great (8%, 10%, 15%) but the order book on one leg is so thin that executing $500 moves the price 5%. The realized spread after slippage is zero or negative. The scanner filters these out via min_liquidity checks, but you'll still see borderline cases — read the order book depth column carefully.

Resolution trap. This is the one our resolution_compatible=True filter catches that nothing else does. Two markets that look identical resolve on different criteria — different disclosure rules, different source authorities, different timing windows. The spread isn't an inefficiency; it's two different bets. We covered the structural reasoning for this in BB#1, and it's not theoretical: recent high-profile disputes over corporate disclosure timing have seen $80M+ in volume resolve based on whether the disclosure date or the event date controlled the outcome. Same event, two answers, depending entirely on how the contract was drafted.

If you ever find yourself building cross-venue arbitrage on title-matching alone, this is the failure mode that will eat you.

What This Tutorial Doesn't Cover

Honest caveats — what we're not going to pretend we solved in 30 minutes.

Execution latency. Polymarket settles on-chain (Polygon). Kalshi settles off-chain. The execution path from "scanner alerts" to "both legs filled" can be 30-60+ seconds on a good day, longer if either platform has elevated gas or congestion. By the time you've filled both legs, the spread may have closed. Live arbitrage execution is a much harder problem than detection, and high-frequency arbitrage on cross-chain prediction markets is currently more theoretical than practical for most retail traders.

Capital requirements. To trade a spread, you need capital on both venues simultaneously. Each venue has its own KYC, funding, and withdrawal cycle. Real cross-venue traders maintain working capital on every venue they trade — meaningful operational overhead.

Regulatory considerations. Kalshi requires US KYC. Polymarket's international platform isn't supposed to be available to US users. If you're physically in the US, you cannot legally trade both sides of a cross-venue spread between Kalshi and Polymarket international. This matters and the scanner doesn't know where you are.

Market making vs taking. This tutorial assumes you're taking — hitting bids and asks on both legs. Market making (placing limit orders on both sides) is a different game with different economics and a different code structure.

Resolution risk during the hold. Even if you've found a real spread and executed both legs, you're holding the position until resolution. That can be days to months. Things change. New information arrives. Capital is locked up. The spread you locked in at execution is only realized if both markets resolve as expected.

None of these caveats invalidate the approach. They just mean live arbitrage trading is harder than the scanner output suggests. The scanner finds candidates. Turning candidates into realized profit is execution engineering.

Why This Is Hard Without an Aggregator

Building this from scratch against direct platform APIs is a multi-week project. Here's what you'd actually need to do:

  • Authenticate against three different APIs with three different auth schemes (Polymarket signed requests, Kalshi token-based, Limitless API key)

  • Pull market data in three different response formats and normalize into a unified schema

  • Build a market-matching algorithm that compares structure rather than titles (this alone is several days of work with rapidly diminishing accuracy without resolution metadata)

  • Track each platform's rate limits independently and back off appropriately

  • Maintain ongoing handling for every platform API change

  • Build an order book aggregator across three different orderbook formats

  • Implement liquidity scoring (BB#1's Finding #2)

  • Reconcile resolution timing edge cases (BB#1's Finding #1)

That's a two-to-three week build, conservatively, before you've evaluated a single opportunity. After that, you'd need to maintain the integration layer indefinitely — every time a platform changes its API, your scanner breaks.

The API exists to make this an afternoon instead of a project. The get_divergences endpoint returns structurally-validated, resolution-compatible, orderbook-enriched candidates in one call. Your engineering effort goes into the strategy logic — fee modeling, slippage estimation, position sizing, execution — instead of the plumbing.

Get Started

The scanner code in this post requires the Pro tier or above (cross-venue divergences + orderbook depth). Free tier covers market discovery and basic price access, useful for prototyping.

  • Free — 1 API key, market metadata + current prices, 60 rpm

  • Developer ($49/mo) — Markets + trades, 100 rpm

  • Pro ($199/mo) — Everything in this tutorial: cross-venue divergences, orderbook depth, OHLCV at all resolutions

  • Business ($499/mo) — Adds on-chain wallet analytics for the smart-money tracking patterns

Get your API key at data.assymetrix.com. The built-in playground lets you run get_divergences against your key before you write a line of integration code.

If you build something interesting with this, or if you find structural edge cases the API doesn't handle well, email dean@assymetrix.com. We prioritize developer feedback heavily on the API roadmap — particularly anything that tightens the resolution-compatibility filter, which is the single most important piece of this whole pipeline.

This is the third Builder Brief from Assymetrix.

Previous: "Backtesting Prediction Market Strategies with 200M+ Price Snapshots and the Assymetrix Data API"

Related: "We Indexed Every Prediction Market Into One Schema. Here's What We Found." — the post that explains why resolution_compatible matters and what the canonical schema does under the hood.

Built for builders who need a prediction-market-native data layer.

data.assymetrix.com


Other Blog