> "Give me a place to stand, and I shall move the world." — Archimedes
In This Chapter
- 16.1 What Is Arbitrage?
- 16.2 Within-Platform Arbitrage
- 16.3 Cross-Platform Arbitrage
- 16.4 Temporal Arbitrage
- 16.5 Related-Market Arbitrage
- 16.6 The Arbitrage Calculation
- 16.7 Execution Challenges
- 16.8 Risks in "Risk-Free" Trades
- 16.9 Building an Arbitrage Bot
- 16.10 Advanced: Statistical Arbitrage
- 16.11 Chapter Summary
- What's Next
Chapter 16: Arbitrage in Prediction Markets
"Give me a place to stand, and I shall move the world." — Archimedes
"Give me a mispricing to exploit, and I shall make risk-free profit." — Every arbitrageur, ever
Arbitrage is the holy grail of trading. The word conjures images of guaranteed profits, free money, and mathematical certainty. In traditional finance, arbitrage opportunities are rare, fleeting, and fiercely competed over by firms spending billions on infrastructure. In prediction markets, however, the landscape is different. Fragmentation across platforms, varying fee structures, behavioral biases among participants, and the relative immaturity of the ecosystem create opportunities that simply do not exist in more established financial markets.
This chapter provides an exhaustive treatment of arbitrage in prediction markets. We begin with precise definitions, move through every major category of arbitrage opportunity, develop the mathematics of fee-adjusted profit calculation, confront the real-world execution challenges head-on, and ultimately build the tools you need to detect and exploit these opportunities systematically. By the end, you will understand not just what arbitrage is, but exactly how to find it, calculate it, and execute it — along with a clear-eyed view of the risks that lurk beneath the surface of every "risk-free" trade.
16.1 What Is Arbitrage?
16.1.1 The Classical Definition
In its purest form, arbitrage is the simultaneous purchase and sale of the same asset (or equivalent assets) in different markets to profit from a price difference, with zero net investment and zero risk. The key requirements are:
- No net capital at risk: The combined position has no downside.
- Guaranteed profit: The payoff is positive in every possible outcome.
- Simultaneous execution: Both legs of the trade happen at the same time, eliminating market risk.
In prediction markets, the simplest example is a binary market where you can buy YES at 45 cents and NO at 50 cents on the same platform. Since YES + NO must pay out exactly $1.00 (one of them will resolve to $1, the other to $0), you pay $0.95 total and receive $1.00 guaranteed. That is a $0.05 risk-free profit per share, or approximately 5.26% return.
16.1.2 True Arbitrage vs. Quasi-Arbitrage
Not all opportunities labeled "arbitrage" are truly risk-free. It is essential to distinguish between:
True Arbitrage: - Mathematically guaranteed profit - No possible outcome results in a loss - Both legs can be executed simultaneously - Same underlying event with identical resolution criteria
Quasi-Arbitrage: - Statistically likely profit but not guaranteed - Small probability of loss exists - Execution may not be perfectly simultaneous - Related but not identical events or resolution criteria
Most opportunities in prediction markets fall into the quasi-arbitrage category. A price discrepancy between Polymarket and Kalshi on "the same" event may involve subtly different resolution criteria, different settlement dates, or different interpretations of edge cases. Understanding this distinction is critical: quasi-arbitrage can still be highly profitable, but you must size positions appropriately and account for the tail risk.
16.1.3 Why Arbitrage Opportunities Exist in Prediction Markets
In efficient financial markets, arbitrage opportunities are eliminated almost instantly by high-frequency traders and market makers. Prediction markets, however, exhibit persistent inefficiencies for several reasons:
Fragmentation: Unlike stocks traded on interconnected exchanges, prediction markets operate on isolated platforms (Polymarket, Kalshi, PredictIt, Metaculus, Manifold, and others). There is no consolidated tape, no cross-platform order routing, and often no easy way to move capital between platforms quickly.
Fee Structures: Different platforms charge different fees — trading fees, withdrawal fees, settlement fees. These create natural "arbitrage bounds" where a price discrepancy exists but is not profitable after fees. However, many participants do not properly account for fees, creating opportunities for those who do.
Behavioral Biases: Prediction market participants exhibit well-documented biases — favorite-longshot bias (overpricing low-probability events), recency bias (overreacting to recent news), and anchoring (insufficient adjustment from initial prices). These biases create systematic mispricings.
Liquidity Constraints: Many markets are thinly traded. A large order on one platform can move the price significantly, creating temporary discrepancies with other platforms or related markets.
Regulatory Barriers: Different platforms operate under different regulatory frameworks. PredictIt has position limits ($850 per contract). Polymarket requires cryptocurrency. Kalshi operates as a regulated exchange. These barriers prevent capital from flowing freely to eliminate price differences.
Information Asymmetry Across Platforms: News may be incorporated into prices at different speeds on different platforms, depending on the composition and sophistication of each platform's user base.
16.1.4 The Fundamental Arbitrage Equation
For any binary prediction market, the fundamental no-arbitrage condition is:
$$P(\text{YES}) + P(\text{NO}) = 1.00$$
When we include fees, the effective cost of establishing a position changes. Let $f_b$ be the fee rate on buying and $f_s$ be the fee rate on selling (or settlement). The no-arbitrage bounds become:
$$P(\text{YES}) \cdot (1 + f_b) + P(\text{NO}) \cdot (1 + f_b) \geq 1.00$$
An arbitrage opportunity exists when:
$$P(\text{YES}) + P(\text{NO}) < \frac{1.00}{1 + f_b}$$
or equivalently, when the total cost of buying both sides, including all fees, is less than the guaranteed payout.
For multi-outcome markets with $n$ mutually exclusive and exhaustive outcomes:
$$\sum_{i=1}^{n} P_i = 1.00$$
An arbitrage opportunity exists when:
$$\sum_{i=1}^{n} P_i \neq 1.00 \quad \text{(after fee adjustment)}$$
If the sum is less than 1.00, you can buy all outcomes and guarantee a profit. If the sum is greater than 1.00, you can sell (short) all outcomes and guarantee a profit (if the platform allows selling).
16.2 Within-Platform Arbitrage
Within-platform arbitrage occurs when mispricing exists on a single platform. This is the simplest form of arbitrage to detect and execute because both legs of the trade happen on the same platform, eliminating cross-platform execution risk.
16.2.1 Binary Market Mispricing
The most basic form: a YES/NO market where the prices do not sum to $1.00.
Example: "Will the Fed raise rates in March 2026?" - YES trading at $0.42 - NO trading at $0.55 - Total: $0.97
You buy one YES share ($0.42) and one NO share ($0.55) for a total of $0.97. Regardless of the outcome, one share pays $1.00. Your guaranteed profit is $0.03 per pair, or 3.09% return.
However, we must account for fees. If the platform charges a 2% fee on profits: - If YES wins: Profit on YES = $0.58, fee = $0.58 * 0.02 = $0.0116. You also lose $0.55 on NO. Net = $1.00 - $0.0116 - $0.97 = $0.0184 - If NO wins: Profit on NO = $0.45, fee = $0.45 * 0.02 = $0.009. You also lose $0.42 on YES. Net = $1.00 - $0.009 - $0.97 = $0.021
After fees, the worst case still yields a profit of $0.0184 per pair, or about 1.9% return. The arbitrage survives fees in this case.
16.2.2 Multi-Outcome Market Mispricing
Multi-outcome markets are more fertile ground for within-platform arbitrage because the complexity of maintaining correct prices across many outcomes is much greater.
Example: "Who will win the 2028 Democratic Primary?" - Candidate A: $0.35 - Candidate B: $0.25 - Candidate C: $0.15 - Candidate D: $0.10 - Candidate E: $0.05 - Field (anyone else): $0.05 - Total: $0.95
Buying one share of every outcome costs $0.95, but exactly one will pay $1.00. Guaranteed profit: $0.05 per complete set, or 5.26% return.
In practice, multi-outcome markets frequently have sums that deviate from $1.00. The "overround" (sum > $1.00) is common and represents the market maker's edge, analogous to the vig in sports betting. But when the sum drops below $1.00, the opportunity is on the buyer's side.
16.2.3 Detecting Within-Platform Arbitrage
The detection algorithm is straightforward:
- Fetch all prices for a given market.
- For binary markets: check if best_ask(YES) + best_ask(NO) < 1.00 (after fees).
- For multi-outcome markets: check if sum of all best_ask prices < 1.00 (after fees).
- Calculate the fee-adjusted profit.
- Filter for opportunities above your minimum profit threshold.
"""Within-platform arbitrage scanner for prediction markets."""
from dataclasses import dataclass
@dataclass
class BinaryMarket:
"""Represents a binary prediction market."""
market_id: str
question: str
yes_ask: float # Best price to buy YES
no_ask: float # Best price to buy NO
yes_bid: float # Best price to sell YES
no_bid: float # Best price to sell NO
platform: str
volume: float = 0.0
@dataclass
class MultiOutcomeMarket:
"""Represents a multi-outcome prediction market."""
market_id: str
question: str
outcomes: dict # {outcome_name: ask_price}
platform: str
volume: float = 0.0
def scan_binary_arb(market: BinaryMarket, fee_rate: float = 0.02) -> dict:
"""
Check a binary market for within-platform arbitrage.
Args:
market: BinaryMarket with current prices.
fee_rate: Platform fee rate on profits (e.g., 0.02 for 2%).
Returns:
Dictionary with arbitrage details or None if no opportunity.
"""
total_cost = market.yes_ask + market.no_ask
payout = 1.00
if total_cost >= payout:
return None
# Calculate worst-case profit after fees
# If YES wins: profit on YES = (1 - yes_ask), fee on profit
# If NO wins: profit on NO = (1 - no_ask), fee on profit
profit_if_yes = (1.0 - market.yes_ask) - (1.0 - market.yes_ask) * fee_rate - market.no_ask
profit_if_no = (1.0 - market.no_ask) - (1.0 - market.no_ask) * fee_rate - market.yes_ask
# The guaranteed profit is the minimum of both scenarios
# Simplification: net = payout - total_cost - fee on winning leg
worst_case_profit = min(profit_if_yes, profit_if_no)
if worst_case_profit <= 0:
return None
return {
"market_id": market.market_id,
"question": market.question,
"platform": market.platform,
"yes_ask": market.yes_ask,
"no_ask": market.no_ask,
"total_cost": total_cost,
"gross_profit": payout - total_cost,
"worst_case_net_profit": worst_case_profit,
"return_pct": (worst_case_profit / total_cost) * 100,
"type": "binary_within_platform",
}
def scan_multi_outcome_arb(
market: MultiOutcomeMarket, fee_rate: float = 0.02
) -> dict:
"""
Check a multi-outcome market for within-platform arbitrage.
Args:
market: MultiOutcomeMarket with current ask prices.
fee_rate: Platform fee rate on profits.
Returns:
Dictionary with arbitrage details or None.
"""
total_cost = sum(market.outcomes.values())
payout = 1.00
if total_cost >= payout:
return None
gross_profit = payout - total_cost
# Worst case: the most expensive outcome wins (smallest profit,
# therefore largest fee relative to profit)
max_price = max(market.outcomes.values())
winning_profit = payout - max_price
fee = winning_profit * fee_rate
worst_case_net = gross_profit - fee
if worst_case_net <= 0:
return None
return {
"market_id": market.market_id,
"question": market.question,
"platform": market.platform,
"outcomes": market.outcomes,
"num_outcomes": len(market.outcomes),
"total_cost": total_cost,
"gross_profit": gross_profit,
"worst_case_net_profit": worst_case_net,
"return_pct": (worst_case_net / total_cost) * 100,
"type": "multi_outcome_within_platform",
}
# --- Demonstration ---
if __name__ == "__main__":
# Binary market example
binary_mkt = BinaryMarket(
market_id="fed-rate-march-2026",
question="Will the Fed raise rates in March 2026?",
yes_ask=0.42,
no_ask=0.55,
yes_bid=0.40,
no_bid=0.53,
platform="ExamplePlatform",
volume=50000,
)
result = scan_binary_arb(binary_mkt, fee_rate=0.02)
if result:
print("=== Binary Arbitrage Found ===")
for k, v in result.items():
print(f" {k}: {v}")
# Multi-outcome market example
multi_mkt = MultiOutcomeMarket(
market_id="dem-primary-2028",
question="Who will win the 2028 Democratic Primary?",
outcomes={
"Candidate A": 0.35,
"Candidate B": 0.25,
"Candidate C": 0.15,
"Candidate D": 0.10,
"Candidate E": 0.05,
"Field": 0.05,
},
platform="ExamplePlatform",
volume=120000,
)
result = scan_multi_outcome_arb(multi_mkt, fee_rate=0.02)
if result:
print("\n=== Multi-Outcome Arbitrage Found ===")
for k, v in result.items():
print(f" {k}: {v}")
16.2.4 Practical Considerations
Order Book Depth: The prices shown are typically the best bid/ask. To execute a meaningful position, you need sufficient depth at those prices. Always check the order book depth before attempting to execute.
Timing: Within-platform arbitrage can be fleeting. Between the time you identify the opportunity and execute both legs, prices may move. Use limit orders rather than market orders to control execution prices, but accept that limit orders may not fill.
Position Limits: Some platforms (notably PredictIt) impose position limits. If you are already near your limit on one side, you cannot fully exploit the arbitrage.
Market Mechanics: Some platforms use continuous double auctions (order books), while others use automated market makers (AMMs). AMM-based platforms adjust prices automatically as orders are filled, which means the price you see for the second leg may change after you execute the first leg.
16.3 Cross-Platform Arbitrage
Cross-platform arbitrage exploits price differences for the same event across different prediction market platforms. This is the most common and often the most profitable form of arbitrage in prediction markets, but it also introduces significant execution challenges.
16.3.1 The Opportunity
Different platforms attract different user bases with different beliefs, information sets, and risk tolerances. A political event might trade at 60 cents on Polymarket (where crypto-native traders dominate) and 55 cents on Kalshi (where more traditional traders participate). If these represent the same underlying event with the same resolution criteria, the 5-cent difference is an arbitrage opportunity.
Cross-platform arbitrage trade structure: - Buy YES on the cheaper platform - Buy NO on the more expensive platform (equivalent to selling YES) - Guarantee profit regardless of outcome
Example: "Will Bitcoin exceed $100,000 by December 31, 2026?" - Polymarket: YES at $0.62, NO at $0.40 - Kalshi: YES at $0.55, NO at $0.47
Trade: Buy YES on Kalshi at $0.55, buy NO on Polymarket at $0.40. Total cost: $0.95.
- If YES: Win $1.00 on Kalshi, lose $0.40 on Polymarket. Net before fees: $1.00 - $0.95 = $0.05
- If NO: Lose $0.55 on Kalshi, win $1.00 on Polymarket. Net before fees: $1.00 - $0.95 = $0.05
Guaranteed $0.05 profit per pair, or 5.26% return.
16.3.2 Fee-Adjusted Calculations
Each platform has its own fee structure, and you must account for fees on both platforms:
| Platform | Trading Fee | Settlement Fee | Withdrawal Fee |
|---|---|---|---|
| Polymarket | 0% (maker) / ~1% (taker) | 0% | Gas fees (variable) |
| Kalshi | $0.01-0.07 per contract | 0% | 0% |
| PredictIt | 0% | 10% on profits | 5% on withdrawals |
The fee-adjusted arbitrage calculation becomes:
$$\pi_{net} = \min(\pi_{\text{YES wins}}, \pi_{\text{NO wins}})$$
where:
$$\pi_{\text{YES wins}} = (1 - f_{s,A}) \cdot (1 - P_{\text{YES},A}) - P_{\text{NO},B} \cdot (1 + f_{b,B})$$ $$\pi_{\text{NO wins}} = (1 - f_{s,B}) \cdot (1 - P_{\text{NO},B}) - P_{\text{YES},A} \cdot (1 + f_{b,A})$$
Wait — let us be more precise. Suppose you buy YES on platform A at price $p_A$ and buy NO on platform B at price $p_B$. Your total cost is $p_A + p_B$ plus any buy-side fees.
If the event occurs (YES wins): - Platform A: You receive $1.00, minus any settlement fee on the profit $(1 - p_A)$ - Platform B: You receive $0.00, losing your $p_B$ investment - Net = $(1 - f_{s,A})(1 - p_A) + p_A - p_A - p_B = (1 - f_{s,A})(1 - p_A) - p_B$
Actually, let us simplify. Your payout from the winning platform is $1.00 minus fees on profit. Your loss on the losing platform is the price you paid.
$$\pi_{\text{YES wins}} = [1.00 - f_{s,A} \cdot (1.00 - p_A)] - p_A - p_B$$ $$\pi_{\text{NO wins}} = [1.00 - f_{s,B} \cdot (1.00 - p_B)] - p_A - p_B$$
And the guaranteed profit is:
$$\pi_{net} = \min(\pi_{\text{YES wins}}, \pi_{\text{NO wins}})$$
16.3.3 Cross-Platform Scanner
"""Cross-platform arbitrage scanner for prediction markets."""
from dataclasses import dataclass, field
from itertools import combinations
@dataclass
class PlatformFees:
"""Fee structure for a prediction market platform."""
name: str
trading_fee_buy: float = 0.0 # Fee on purchase (% of price)
trading_fee_sell: float = 0.0 # Fee on sale (% of price)
settlement_fee: float = 0.0 # Fee on profit at settlement (% of profit)
withdrawal_fee: float = 0.0 # Fee on withdrawal (% of amount)
per_contract_fee: float = 0.0 # Fixed fee per contract
PLATFORM_FEES = {
"polymarket": PlatformFees("Polymarket", trading_fee_buy=0.01),
"kalshi": PlatformFees("Kalshi", per_contract_fee=0.03),
"predictit": PlatformFees("PredictIt", settlement_fee=0.10,
withdrawal_fee=0.05),
"metaculus": PlatformFees("Metaculus"),
}
@dataclass
class MarketListing:
"""A market listing on a specific platform."""
event_id: str # Canonical event identifier
platform: str
yes_price: float
no_price: float
yes_volume: float = 0.0
no_volume: float = 0.0
max_position: float = float("inf") # Position limit
def calculate_cross_platform_arb(
listing_a: MarketListing,
listing_b: MarketListing,
fees_a: PlatformFees,
fees_b: PlatformFees,
) -> dict:
"""
Calculate cross-platform arbitrage between two listings.
Strategy: Buy YES on the platform where it is cheaper,
buy NO on the other platform.
Args:
listing_a: First platform listing.
listing_b: Second platform listing.
fees_a: Fee structure for platform A.
fees_b: Fee structure for platform B.
Returns:
Dictionary with best arbitrage opportunity, or None.
"""
opportunities = []
# Strategy 1: Buy YES on A, Buy NO on B
cost_yes_a = listing_a.yes_price * (1 + fees_a.trading_fee_buy) + fees_a.per_contract_fee
cost_no_b = listing_b.no_price * (1 + fees_b.trading_fee_buy) + fees_b.per_contract_fee
total_cost_1 = cost_yes_a + cost_no_b
# If YES wins: collect $1 from A minus settlement fee on profit
profit_a_yes = 1.0 - listing_a.yes_price
payout_yes_wins = 1.0 - fees_a.settlement_fee * profit_a_yes
net_yes_wins_1 = payout_yes_wins - total_cost_1
# If NO wins: collect $1 from B minus settlement fee on profit
profit_b_no = 1.0 - listing_b.no_price
payout_no_wins = 1.0 - fees_b.settlement_fee * profit_b_no
net_no_wins_1 = payout_no_wins - total_cost_1
guaranteed_1 = min(net_yes_wins_1, net_no_wins_1)
if guaranteed_1 > 0:
opportunities.append({
"strategy": f"Buy YES on {listing_a.platform}, Buy NO on {listing_b.platform}",
"buy_yes_platform": listing_a.platform,
"buy_no_platform": listing_b.platform,
"yes_price": listing_a.yes_price,
"no_price": listing_b.no_price,
"total_cost": round(total_cost_1, 4),
"guaranteed_profit": round(guaranteed_1, 4),
"return_pct": round((guaranteed_1 / total_cost_1) * 100, 2),
"max_size": min(listing_a.max_position, listing_b.max_position),
})
# Strategy 2: Buy YES on B, Buy NO on A
cost_yes_b = listing_b.yes_price * (1 + fees_b.trading_fee_buy) + fees_b.per_contract_fee
cost_no_a = listing_a.no_price * (1 + fees_a.trading_fee_buy) + fees_a.per_contract_fee
total_cost_2 = cost_yes_b + cost_no_a
profit_b_yes = 1.0 - listing_b.yes_price
payout_yes_wins_2 = 1.0 - fees_b.settlement_fee * profit_b_yes
net_yes_wins_2 = payout_yes_wins_2 - total_cost_2
profit_a_no = 1.0 - listing_a.no_price
payout_no_wins_2 = 1.0 - fees_a.settlement_fee * profit_a_no
net_no_wins_2 = payout_no_wins_2 - total_cost_2
guaranteed_2 = min(net_yes_wins_2, net_no_wins_2)
if guaranteed_2 > 0:
opportunities.append({
"strategy": f"Buy YES on {listing_b.platform}, Buy NO on {listing_a.platform}",
"buy_yes_platform": listing_b.platform,
"buy_no_platform": listing_a.platform,
"yes_price": listing_b.yes_price,
"no_price": listing_a.no_price,
"total_cost": round(total_cost_2, 4),
"guaranteed_profit": round(guaranteed_2, 4),
"return_pct": round((guaranteed_2 / total_cost_2) * 100, 2),
"max_size": min(listing_a.max_position, listing_b.max_position),
})
if not opportunities:
return None
# Return the best opportunity
return max(opportunities, key=lambda x: x["return_pct"])
def scan_all_platforms(listings: list, platform_fees: dict) -> list:
"""
Scan all platform pairs for arbitrage opportunities.
Args:
listings: List of MarketListing objects for the same event.
platform_fees: Dictionary mapping platform name to PlatformFees.
Returns:
List of arbitrage opportunities sorted by return.
"""
results = []
for a, b in combinations(listings, 2):
fees_a = platform_fees.get(a.platform.lower(), PlatformFees(a.platform))
fees_b = platform_fees.get(b.platform.lower(), PlatformFees(b.platform))
opp = calculate_cross_platform_arb(a, b, fees_a, fees_b)
if opp:
opp["event_id"] = a.event_id
results.append(opp)
results.sort(key=lambda x: x["return_pct"], reverse=True)
return results
# --- Demonstration ---
if __name__ == "__main__":
listings = [
MarketListing("btc-100k-2026", "Polymarket", 0.62, 0.40, 500000, 300000),
MarketListing("btc-100k-2026", "Kalshi", 0.55, 0.47, 200000, 150000),
MarketListing("btc-100k-2026", "PredictIt", 0.58, 0.44, 100000, 80000,
max_position=850),
]
opps = scan_all_platforms(listings, PLATFORM_FEES)
if opps:
print(f"Found {len(opps)} cross-platform arbitrage opportunities:\n")
for i, opp in enumerate(opps, 1):
print(f"--- Opportunity {i} ---")
for k, v in opp.items():
print(f" {k}: {v}")
print()
else:
print("No cross-platform arbitrage opportunities found.")
16.3.4 Resolution Risk: The Hidden Danger
The single greatest risk in cross-platform arbitrage is resolution risk — the possibility that two platforms resolve the "same" event differently. This can happen because:
- Different resolution criteria: One platform may define "winning the election" as being declared winner by the Associated Press, while another requires certification by the Electoral College.
- Different resolution dates: One platform may resolve at year-end, another at a specific date within the year.
- Ambiguity in edge cases: What happens if a candidate drops out? What if the event is cancelled? Each platform has its own rules.
- Different "N/A" or "void" policies: If a market is voided on one platform but resolves on another, you can end up with a loss on one side and a refund on the other.
Mitigation: Always read the full resolution criteria on both platforms before entering a cross-platform arbitrage. If there is any ambiguity, either skip the trade or reduce position size to account for the resolution risk.
16.3.5 Capital Efficiency Across Platforms
Cross-platform arbitrage requires capital deployed on multiple platforms simultaneously. This creates capital efficiency challenges:
- Fragmented capital: $10,000 split across three platforms means only $3,333 per platform.
- Withdrawal delays: Moving capital from one platform to another may take days (ACH transfers) or involve significant fees (crypto gas fees).
- Opportunity cost: Capital locked in an arbitrage position until settlement cannot be used for other trades.
The annualized return calculation is critical:
$$R_{annual} = \left(\frac{\pi_{net}}{C_{total}}\right) \cdot \frac{365}{T_{days}}$$
where $T_{days}$ is the number of days until the market settles. An arbitrage yielding 3% that settles in one week (annualized: 156%) is vastly more attractive than one yielding 5% that settles in six months (annualized: 10%).
16.4 Temporal Arbitrage
Temporal arbitrage exploits price discrepancies that arise across time rather than across platforms. Unlike spatial arbitrage (cross-platform), temporal arbitrage involves buying at one point in time and selling at another, profiting from predictable price movements.
16.4.1 Sources of Temporal Mispricing
Delayed Information Incorporation: When news breaks, prices on prediction markets do not update instantaneously. There is a latency between the news event and the full incorporation of that information into prices. Traders who process information faster can buy before the price adjusts.
Time-Zone Effects: Markets may be less actively traded during certain hours. If a US political event is announced during Asian trading hours, the price adjustment may be incomplete until US traders come online.
Scheduled Events: Prices often exhibit predictable patterns around scheduled information releases (economic data, court decisions, vote counts). The resolution of uncertainty itself can be traded.
Weekend and Holiday Effects: Prediction markets often see reduced liquidity on weekends and holidays, leading to stale prices that do not reflect the latest information.
Expiration Convergence: As a market approaches its resolution date, the price must converge to either 0 or 1. Prices that are "stuck" far from their terminal value offer opportunities if you can identify the correct direction.
16.4.2 The Speed Advantage
Temporal arbitrage is fundamentally about speed. The trader who incorporates information faster earns the profit. This creates a hierarchy:
- Automated systems monitoring news feeds, APIs, and social media can react in milliseconds.
- Active manual traders monitoring multiple sources can react in seconds to minutes.
- Casual participants who check markets periodically may not react for hours or days.
The profit from temporal arbitrage is the price difference between the "stale" price and the "correct" price, minus transaction costs.
16.4.3 Temporal Arbitrage Detector
"""Temporal arbitrage detector for prediction markets."""
import statistics
from dataclasses import dataclass
from datetime import datetime, timedelta
@dataclass
class PriceSnapshot:
"""A snapshot of a market's price at a point in time."""
market_id: str
platform: str
timestamp: datetime
yes_price: float
no_price: float
volume_24h: float
bid_ask_spread: float
class TemporalArbDetector:
"""Detects temporal arbitrage opportunities from price history."""
def __init__(self, lookback_minutes: int = 60,
min_price_move: float = 0.03,
min_volume_ratio: float = 0.5):
self.lookback_minutes = lookback_minutes
self.min_price_move = min_price_move
self.min_volume_ratio = min_volume_ratio
self.price_history: dict = {} # market_id -> list of PriceSnapshot
def add_snapshot(self, snapshot: PriceSnapshot):
"""Add a price snapshot to the history."""
key = f"{snapshot.market_id}_{snapshot.platform}"
if key not in self.price_history:
self.price_history[key] = []
self.price_history[key].append(snapshot)
def detect_stale_prices(self, current_time: datetime) -> list:
"""
Detect markets where prices may be stale (not recently updated
despite activity in related markets).
Returns:
List of potentially stale markets with analysis.
"""
stale = []
cutoff = current_time - timedelta(minutes=self.lookback_minutes)
for key, snapshots in self.price_history.items():
recent = [s for s in snapshots if s.timestamp > cutoff]
if len(recent) < 2:
continue
# Check if price has been flat despite volume
prices = [s.yes_price for s in recent]
price_range = max(prices) - min(prices)
avg_volume = statistics.mean([s.volume_24h for s in recent])
if price_range < 0.01 and avg_volume > 10000:
stale.append({
"market_id": recent[-1].market_id,
"platform": recent[-1].platform,
"current_price": recent[-1].yes_price,
"price_range": price_range,
"avg_volume": avg_volume,
"last_update": recent[-1].timestamp.isoformat(),
"signal": "STALE - flat price despite volume",
})
return stale
def detect_delayed_moves(self, reference_platform: str) -> list:
"""
Detect markets where one platform has moved but others have not yet.
Args:
reference_platform: The platform considered the 'leader'.
Returns:
List of delayed-move opportunities.
"""
# Group by market_id
markets = {}
for key, snapshots in self.price_history.items():
if not snapshots:
continue
mid = snapshots[-1].market_id
if mid not in markets:
markets[mid] = {}
markets[mid][snapshots[-1].platform] = snapshots
opportunities = []
for mid, platforms in markets.items():
if reference_platform not in platforms:
continue
if len(platforms) < 2:
continue
ref_snaps = platforms[reference_platform]
if len(ref_snaps) < 2:
continue
# Calculate recent price move on reference platform
ref_move = ref_snaps[-1].yes_price - ref_snaps[-2].yes_price
if abs(ref_move) < self.min_price_move:
continue
# Check if other platforms have followed
for plat, snaps in platforms.items():
if plat == reference_platform:
continue
if len(snaps) < 2:
continue
other_move = snaps[-1].yes_price - snaps[-2].yes_price
lag = ref_move - other_move
if abs(lag) >= self.min_price_move:
direction = "BUY YES" if lag > 0 else "BUY NO"
opportunities.append({
"market_id": mid,
"reference_platform": reference_platform,
"lagging_platform": plat,
"ref_price": ref_snaps[-1].yes_price,
"lagging_price": snaps[-1].yes_price,
"ref_move": round(ref_move, 4),
"lagging_move": round(other_move, 4),
"gap": round(lag, 4),
"suggested_action": direction,
"signal": "DELAYED_MOVE",
})
return opportunities
def detect_mean_reversion(self, window: int = 20,
threshold_std: float = 2.0) -> list:
"""
Detect markets where the price has deviated significantly from
its recent mean, suggesting a reversion opportunity.
Args:
window: Number of recent snapshots to consider.
threshold_std: Number of standard deviations for signal.
Returns:
List of mean-reversion opportunities.
"""
opportunities = []
for key, snapshots in self.price_history.items():
if len(snapshots) < window:
continue
recent = snapshots[-window:]
prices = [s.yes_price for s in recent]
mean_price = statistics.mean(prices)
std_price = statistics.stdev(prices)
if std_price < 0.005:
continue
current = prices[-1]
z_score = (current - mean_price) / std_price
if abs(z_score) >= threshold_std:
direction = "BUY NO (price too high)" if z_score > 0 else "BUY YES (price too low)"
opportunities.append({
"market_id": snapshots[-1].market_id,
"platform": snapshots[-1].platform,
"current_price": current,
"mean_price": round(mean_price, 4),
"std_price": round(std_price, 4),
"z_score": round(z_score, 2),
"suggested_action": direction,
"signal": "MEAN_REVERSION",
})
return opportunities
# --- Demonstration ---
if __name__ == "__main__":
detector = TemporalArbDetector(lookback_minutes=120, min_price_move=0.03)
now = datetime(2026, 2, 17, 14, 0, 0)
# Simulate price snapshots showing a delayed move
for i in range(10):
t = now - timedelta(minutes=(10 - i) * 5)
# Polymarket moves from 0.55 to 0.65 over the period
poly_price = 0.55 + (i * 0.01) if i < 10 else 0.65
detector.add_snapshot(PriceSnapshot(
"election-2026", "Polymarket", t, poly_price, 1 - poly_price,
100000, 0.02
))
# Kalshi stays at 0.56 (lagging)
kalshi_price = 0.56 + (i * 0.002)
detector.add_snapshot(PriceSnapshot(
"election-2026", "Kalshi", t, kalshi_price, 1 - kalshi_price,
50000, 0.03
))
delayed = detector.detect_delayed_moves("Polymarket")
if delayed:
print("=== Delayed Move Opportunities ===")
for opp in delayed:
for k, v in opp.items():
print(f" {k}: {v}")
16.4.4 The Information Advantage Framework
Temporal arbitrage is closely related to having an information edge. The framework for evaluating a temporal arbitrage opportunity is:
- Information Quality: How reliable is the new information? False signals lead to losses.
- Information Speed: How quickly are you processing this relative to other market participants?
- Price Impact: How much should the price move in response to this information?
- Execution Capacity: Can you execute a meaningful position before the price adjusts?
- Decay Rate: How quickly will the opportunity disappear?
The expected profit from temporal arbitrage is:
$$E[\pi] = P(\text{correct signal}) \cdot \Delta P \cdot Q - P(\text{incorrect signal}) \cdot L \cdot Q - C_{transaction}$$
where $\Delta P$ is the expected price move, $Q$ is the quantity traded, $L$ is the expected loss on an incorrect signal, and $C_{transaction}$ is the total transaction cost.
16.5 Related-Market Arbitrage
Related-market arbitrage exploits logical relationships between different markets on the same platform or across platforms. Unlike cross-platform arbitrage (same event, different platforms), related-market arbitrage involves different events that are logically connected.
16.5.1 Types of Logical Relationships
Subset/Superset: If market A is a subset of market B, then $P(A) \leq P(B)$. For example, "Will the Democrats win Texas?" is a subset of "Will the Democrats win any Southern state?" If the Texas price exceeds the Southern-state price, there is an arbitrage.
Conditional Probability: $P(A \cap B) \leq \min(P(A), P(B))$. If a market for "A and B both happen" is priced higher than either individual probability, there is an arbitrage.
Complementary Events: If A and B are mutually exclusive and exhaustive, $P(A) + P(B) = 1$. Any deviation is an arbitrage.
Sequential Events: If A must happen before B can happen, $P(B) \leq P(A)$. For example, "Will Candidate X win the general election?" should not be priced higher than "Will Candidate X win the primary?"
Aggregate Constraints: The probability of a national outcome must be consistent with state-level probabilities. For example, the probability of a candidate winning the presidency must be consistent with the probabilities of winning each state (weighted by electoral votes and correlations).
16.5.2 Related-Market Analyzer
"""Related-market arbitrage analyzer for prediction markets."""
from dataclasses import dataclass
@dataclass
class RelatedMarket:
"""A market with known logical relationships to other markets."""
market_id: str
question: str
yes_price: float
platform: str
def check_subset_constraint(
subset_market: RelatedMarket,
superset_market: RelatedMarket,
fee_rate: float = 0.02,
) -> dict:
"""
Check if P(subset) <= P(superset) holds.
If subset is priced higher than superset, arbitrage exists:
sell subset, buy superset.
Args:
subset_market: The market that is logically a subset.
superset_market: The market that is logically a superset.
fee_rate: Fee rate on profits.
Returns:
Arbitrage opportunity dict or None.
"""
if subset_market.yes_price <= superset_market.yes_price:
return None
gap = subset_market.yes_price - superset_market.yes_price
# After fees, is it still profitable?
net_gap = gap * (1 - fee_rate)
if net_gap <= 0:
return None
return {
"type": "SUBSET_VIOLATION",
"subset_market": subset_market.market_id,
"superset_market": superset_market.market_id,
"subset_price": subset_market.yes_price,
"superset_price": superset_market.yes_price,
"price_gap": round(gap, 4),
"net_profit_per_pair": round(net_gap, 4),
"action": (
f"Sell YES on '{subset_market.question}' at "
f"${subset_market.yes_price:.2f}, "
f"Buy YES on '{superset_market.question}' at "
f"${superset_market.yes_price:.2f}"
),
}
def check_conditional_constraint(
joint_market: RelatedMarket,
marginal_a: RelatedMarket,
marginal_b: RelatedMarket,
fee_rate: float = 0.02,
) -> dict:
"""
Check if P(A and B) <= min(P(A), P(B)).
Args:
joint_market: Market for "A and B both happen".
marginal_a: Market for "A happens".
marginal_b: Market for "B happens".
fee_rate: Fee rate on profits.
Returns:
Arbitrage opportunity dict or None.
"""
min_marginal = min(marginal_a.yes_price, marginal_b.yes_price)
if joint_market.yes_price <= min_marginal:
return None
gap = joint_market.yes_price - min_marginal
net_gap = gap * (1 - fee_rate)
if net_gap <= 0:
return None
binding = marginal_a if marginal_a.yes_price <= marginal_b.yes_price else marginal_b
return {
"type": "CONDITIONAL_VIOLATION",
"joint_market": joint_market.market_id,
"binding_marginal": binding.market_id,
"joint_price": joint_market.yes_price,
"min_marginal_price": min_marginal,
"price_gap": round(gap, 4),
"net_profit_per_pair": round(net_gap, 4),
"action": (
f"Sell YES on '{joint_market.question}' at "
f"${joint_market.yes_price:.2f}, "
f"Buy YES on '{binding.question}' at "
f"${binding.yes_price:.2f}"
),
}
def check_sequential_constraint(
prerequisite_market: RelatedMarket,
dependent_market: RelatedMarket,
fee_rate: float = 0.02,
) -> dict:
"""
Check if P(dependent) <= P(prerequisite).
If dependent is priced higher than prerequisite, arbitrage exists.
Args:
prerequisite_market: The event that must happen first.
dependent_market: The event that requires the prerequisite.
fee_rate: Fee rate on profits.
Returns:
Arbitrage opportunity dict or None.
"""
if dependent_market.yes_price <= prerequisite_market.yes_price:
return None
gap = dependent_market.yes_price - prerequisite_market.yes_price
net_gap = gap * (1 - fee_rate)
if net_gap <= 0:
return None
return {
"type": "SEQUENTIAL_VIOLATION",
"prerequisite": prerequisite_market.market_id,
"dependent": dependent_market.market_id,
"prerequisite_price": prerequisite_market.yes_price,
"dependent_price": dependent_market.yes_price,
"price_gap": round(gap, 4),
"net_profit_per_pair": round(net_gap, 4),
"action": (
f"Sell YES on '{dependent_market.question}' at "
f"${dependent_market.yes_price:.2f}, "
f"Buy YES on '{prerequisite_market.question}' at "
f"${prerequisite_market.yes_price:.2f}"
),
}
def check_complement_constraint(
markets: list,
expected_sum: float = 1.0,
fee_rate: float = 0.02,
) -> dict:
"""
Check if mutually exclusive and exhaustive markets sum to 1.0.
Args:
markets: List of RelatedMarket objects that should sum to expected_sum.
expected_sum: The expected sum of probabilities (usually 1.0).
fee_rate: Fee rate on profits.
Returns:
Arbitrage opportunity dict or None.
"""
total = sum(m.yes_price for m in markets)
deviation = total - expected_sum
if abs(deviation) < 0.01:
return None
if deviation < 0:
# Sum is less than expected: buy all outcomes
gross_profit = expected_sum - total
fee = max(m.yes_price for m in markets) * fee_rate
net_profit = gross_profit - fee
action_type = "BUY_ALL"
action = "Buy YES on all outcomes (guaranteed one pays out)"
else:
# Sum is greater than expected: sell all outcomes (if possible)
gross_profit = total - expected_sum
fee = gross_profit * fee_rate
net_profit = gross_profit - fee
action_type = "SELL_ALL"
action = "Sell YES on all outcomes (guaranteed profit from overround)"
if net_profit <= 0:
return None
return {
"type": f"COMPLEMENT_VIOLATION_{action_type}",
"markets": [m.market_id for m in markets],
"prices": {m.market_id: m.yes_price for m in markets},
"total": round(total, 4),
"expected_sum": expected_sum,
"deviation": round(deviation, 4),
"gross_profit": round(gross_profit, 4),
"net_profit": round(net_profit, 4),
"action": action,
}
# --- Demonstration ---
if __name__ == "__main__":
# Subset violation example
texas = RelatedMarket("dems-texas", "Will Democrats win Texas?", 0.25, "Polymarket")
south = RelatedMarket("dems-south", "Will Democrats win any Southern state?", 0.22, "Polymarket")
result = check_subset_constraint(texas, south)
if result:
print("=== Subset Violation ===")
for k, v in result.items():
print(f" {k}: {v}")
# Sequential violation example
primary = RelatedMarket("x-primary", "Will X win the primary?", 0.40, "Kalshi")
general = RelatedMarket("x-general", "Will X win the general election?", 0.45, "Kalshi")
result = check_sequential_constraint(primary, general)
if result:
print("\n=== Sequential Violation ===")
for k, v in result.items():
print(f" {k}: {v}")
# Complement violation example
candidates = [
RelatedMarket("cand-a", "Candidate A wins", 0.35, "Polymarket"),
RelatedMarket("cand-b", "Candidate B wins", 0.25, "Polymarket"),
RelatedMarket("cand-c", "Candidate C wins", 0.15, "Polymarket"),
RelatedMarket("cand-d", "Candidate D wins", 0.10, "Polymarket"),
RelatedMarket("cand-e", "Candidate E wins", 0.05, "Polymarket"),
RelatedMarket("field", "Field (other)", 0.05, "Polymarket"),
]
result = check_complement_constraint(candidates)
if result:
print("\n=== Complement Violation ===")
for k, v in result.items():
print(f" {k}: {v}")
16.5.3 The Challenge of Identifying Relationships
The hardest part of related-market arbitrage is not the execution — it is identifying the relationships in the first place. This requires:
- Domain knowledge: Understanding which events are logically connected.
- Careful reading: Parsing the exact resolution criteria to verify the relationship holds.
- Probabilistic reasoning: Some relationships are deterministic (subset/superset), others are probabilistic (correlations).
Automated detection is possible for simple relationships (multi-outcome markets on the same event) but challenging for cross-market relationships that require semantic understanding of the questions.
16.6 The Arbitrage Calculation
Now that we have surveyed the types of arbitrage available, let us formalize the calculation process. Every arbitrage evaluation follows the same steps, regardless of type.
16.6.1 Step 1: Identify the Opportunity
Catalog the prices, platforms, fees, and resolution criteria. Be precise:
| Parameter | Value |
|---|---|
| Event | "Will X happen by date Y?" |
| Platform A | Price: $0.55 YES, Fee: 2% on profit |
| Platform B | Price: $0.40 NO, Fee: $0.03/contract |
| Resolution date | 2026-06-30 |
| Days to settlement | 133 |
16.6.2 Step 2: Calculate Fee-Adjusted Profit
For each possible outcome, calculate the net profit after all fees:
$$\pi_i = \text{Payout}_i - \text{Total Cost} - \text{Fees}_i$$
The guaranteed profit is:
$$\pi_{guaranteed} = \min_i(\pi_i)$$
16.6.3 Step 3: Size the Position
Position sizing depends on: - Available capital on each platform - Order book depth (how much volume is available at the quoted price) - Position limits imposed by the platform - Your personal risk tolerance (even "risk-free" trades have operational risks)
The optimal size is:
$$Q^* = \min(Q_{capital}, Q_{depth}, Q_{limit}, Q_{risk})$$
where: - $Q_{capital}$ = maximum shares affordable given your capital on each platform - $Q_{depth}$ = maximum shares available at the quoted price - $Q_{limit}$ = platform-imposed position limit - $Q_{risk}$ = your maximum acceptable exposure to operational risk
16.6.4 Step 4: Execute Simultaneously
For cross-platform arbitrage, both legs should be executed as close to simultaneously as possible. In practice, this means: - Pre-fund both platforms - Prepare orders on both platforms - Execute the first leg - Immediately execute the second leg - Verify both fills
16.6.5 Step 5: Calculate ROI and Annualized Return
$$ROI = \frac{\pi_{guaranteed}}{C_{total}} \times 100\%$$
$$R_{annual} = \left(1 + \frac{\pi_{guaranteed}}{C_{total}}\right)^{365/T} - 1$$
where $T$ is the number of days until settlement.
16.6.6 Complete Arbitrage Calculator
"""Complete arbitrage calculator with fee adjustment and annualization."""
from dataclasses import dataclass
from datetime import date
@dataclass
class ArbLeg:
"""One leg of an arbitrage trade."""
platform: str
side: str # "YES" or "NO"
price: float
available_qty: int # Depth at this price
max_position: int # Platform position limit
trading_fee_pct: float = 0.0
per_contract_fee: float = 0.0
settlement_fee_pct: float = 0.0
withdrawal_fee_pct: float = 0.0
def calculate_arb(
leg_a: ArbLeg,
leg_b: ArbLeg,
capital_a: float,
capital_b: float,
settlement_date: date,
today: date = None,
) -> dict:
"""
Full arbitrage calculation with fees, sizing, and annualized return.
Assumes leg_a is YES and leg_b is NO (or vice versa) for the same event.
Args:
leg_a: First leg of the arbitrage.
leg_b: Second leg of the arbitrage.
capital_a: Available capital on platform A.
capital_b: Available capital on platform B.
settlement_date: When the market resolves.
today: Current date (defaults to today).
Returns:
Complete arbitrage analysis dictionary.
"""
if today is None:
today = date.today()
days_to_settlement = max((settlement_date - today).days, 1)
# Cost per share for each leg (including fees)
cost_a = leg_a.price * (1 + leg_a.trading_fee_pct) + leg_a.per_contract_fee
cost_b = leg_b.price * (1 + leg_b.trading_fee_pct) + leg_b.per_contract_fee
total_cost_per_pair = cost_a + cost_b
# Calculate payout for each outcome
# If leg_a wins (the event matches leg_a's side):
profit_a = 1.0 - leg_a.price
payout_a_wins = 1.0 - leg_a.settlement_fee_pct * profit_a
# If leg_b wins:
profit_b = 1.0 - leg_b.price
payout_b_wins = 1.0 - leg_b.settlement_fee_pct * profit_b
# Net profit for each outcome
net_a_wins = payout_a_wins - total_cost_per_pair
net_b_wins = payout_b_wins - total_cost_per_pair
guaranteed_profit = min(net_a_wins, net_b_wins)
if guaranteed_profit <= 0:
return {
"is_profitable": False,
"guaranteed_profit_per_pair": round(guaranteed_profit, 4),
"reason": "No arbitrage after fees",
}
# Position sizing
max_pairs_capital_a = int(capital_a / cost_a)
max_pairs_capital_b = int(capital_b / cost_b)
max_pairs_depth = min(leg_a.available_qty, leg_b.available_qty)
max_pairs_limit = min(leg_a.max_position, leg_b.max_position)
optimal_pairs = min(
max_pairs_capital_a,
max_pairs_capital_b,
max_pairs_depth,
max_pairs_limit,
)
total_investment = optimal_pairs * total_cost_per_pair
total_guaranteed_profit = optimal_pairs * guaranteed_profit
# Apply withdrawal fees
withdrawal_fee_a = total_guaranteed_profit * leg_a.withdrawal_fee_pct * 0.5
withdrawal_fee_b = total_guaranteed_profit * leg_b.withdrawal_fee_pct * 0.5
total_after_withdrawal = total_guaranteed_profit - withdrawal_fee_a - withdrawal_fee_b
roi = (total_after_withdrawal / total_investment) * 100 if total_investment > 0 else 0
annualized = ((1 + total_after_withdrawal / total_investment) ** (365 / days_to_settlement) - 1) * 100 if total_investment > 0 else 0
return {
"is_profitable": True,
"leg_a": {
"platform": leg_a.platform,
"side": leg_a.side,
"price": leg_a.price,
"cost_per_share": round(cost_a, 4),
},
"leg_b": {
"platform": leg_b.platform,
"side": leg_b.side,
"price": leg_b.price,
"cost_per_share": round(cost_b, 4),
},
"total_cost_per_pair": round(total_cost_per_pair, 4),
"payout_if_a_wins": round(payout_a_wins, 4),
"payout_if_b_wins": round(payout_b_wins, 4),
"guaranteed_profit_per_pair": round(guaranteed_profit, 4),
"optimal_pairs": optimal_pairs,
"total_investment": round(total_investment, 2),
"total_guaranteed_profit": round(total_after_withdrawal, 2),
"roi_pct": round(roi, 2),
"days_to_settlement": days_to_settlement,
"annualized_return_pct": round(annualized, 2),
"limiting_factor": (
"capital_a" if optimal_pairs == max_pairs_capital_a else
"capital_b" if optimal_pairs == max_pairs_capital_b else
"depth" if optimal_pairs == max_pairs_depth else
"position_limit"
),
}
# --- Demonstration ---
if __name__ == "__main__":
leg_a = ArbLeg(
platform="Polymarket",
side="YES",
price=0.55,
available_qty=5000,
max_position=100000,
trading_fee_pct=0.01,
settlement_fee_pct=0.0,
)
leg_b = ArbLeg(
platform="PredictIt",
side="NO",
price=0.40,
available_qty=850,
max_position=850,
trading_fee_pct=0.0,
settlement_fee_pct=0.10,
withdrawal_fee_pct=0.05,
)
result = calculate_arb(
leg_a, leg_b,
capital_a=5000.0,
capital_b=850.0,
settlement_date=date(2026, 6, 30),
today=date(2026, 2, 17),
)
print("=== Full Arbitrage Calculation ===")
for k, v in result.items():
if isinstance(v, dict):
print(f" {k}:")
for kk, vv in v.items():
print(f" {kk}: {vv}")
else:
print(f" {k}: {v}")
16.7 Execution Challenges
The mathematics of arbitrage is elegant. The execution is not. This section addresses the practical challenges that transform "risk-free" profits on paper into uncertain outcomes in reality.
16.7.1 Simultaneous Execution
True arbitrage requires simultaneous execution of both legs. In practice, there is always a delay between executing the first and second legs. During this delay:
- The price of the second leg may move against you
- The second leg may not fill at all
- You are exposed to market risk on the first leg
Mitigation strategies: - Pre-stage orders on both platforms before executing either - Use APIs for faster execution where available - Start with the less liquid leg (harder to fill), then execute the more liquid leg - Accept slightly worse prices on limit orders to increase fill probability
16.7.2 Partial Fills
Order books may not have sufficient depth at the quoted price. You might want 1,000 shares but only 200 are available at the quoted price. This creates:
- Asymmetric exposure: You have a full position on one platform and a partial position on the other
- Reduced profitability: Filling the remaining shares may require worse prices
- Unwanted risk: The partially filled position is a directional bet, not an arbitrage
16.7.3 Slippage
The price you see is not necessarily the price you get. Slippage occurs due to:
- Market impact: Your order moves the price
- Latency: Price changes between order submission and execution
- AMM mechanics: On AMM-based platforms, price is a function of the trade size
16.7.4 Execution Simulator
"""Execution simulator for arbitrage trades."""
import random
from dataclasses import dataclass
@dataclass
class OrderBook:
"""Simplified order book for simulation."""
levels: list # List of (price, quantity) tuples, sorted by price
def fill_order(self, target_qty: int, max_price: float) -> tuple:
"""
Simulate filling an order against the order book.
Returns:
(filled_qty, avg_price, levels_consumed)
"""
filled = 0
total_cost = 0.0
levels_consumed = 0
for price, qty in self.levels:
if price > max_price:
break
fill_at_level = min(qty, target_qty - filled)
filled += fill_at_level
total_cost += fill_at_level * price
levels_consumed += 1
if filled >= target_qty:
break
avg_price = total_cost / filled if filled > 0 else 0
return filled, avg_price, levels_consumed
class ArbExecutionSimulator:
"""Simulates the execution of an arbitrage trade."""
def __init__(self, latency_ms: float = 500, price_volatility: float = 0.005):
self.latency_ms = latency_ms
self.price_volatility = price_volatility
def simulate_execution(
self,
book_a: OrderBook,
book_b: OrderBook,
target_qty: int,
max_price_a: float,
max_price_b: float,
num_simulations: int = 1000,
) -> dict:
"""
Monte Carlo simulation of arbitrage execution.
Simulates price movement between leg 1 and leg 2 execution,
partial fills, and calculates statistics.
Args:
book_a: Order book for leg A.
book_b: Order book for leg B.
target_qty: Target number of pairs.
max_price_a: Maximum price willing to pay for leg A.
max_price_b: Maximum price willing to pay for leg B.
num_simulations: Number of Monte Carlo runs.
Returns:
Simulation statistics.
"""
results = []
for _ in range(num_simulations):
# Execute leg A (first)
filled_a, avg_price_a, _ = book_a.fill_order(target_qty, max_price_a)
if filled_a == 0:
results.append({
"status": "failed",
"reason": "leg_a_no_fill",
"profit": 0,
})
continue
# Simulate price movement during latency
price_shift = random.gauss(0, self.price_volatility)
# Adjust book B prices by the shift
adjusted_book_b = OrderBook([
(p + price_shift, q) for p, q in book_b.levels
])
# Execute leg B for the quantity we filled on leg A
filled_b, avg_price_b, _ = adjusted_book_b.fill_order(
filled_a, max_price_b
)
if filled_b == 0:
# Leg B failed completely - we have an unwanted directional position
results.append({
"status": "partial_failure",
"reason": "leg_b_no_fill",
"filled_a": filled_a,
"filled_b": 0,
"profit": 0,
"risk": filled_a * avg_price_a,
})
continue
# Calculate profit
matched_qty = min(filled_a, filled_b)
total_cost = matched_qty * (avg_price_a + avg_price_b)
guaranteed_payout = matched_qty * 1.0
profit = guaranteed_payout - total_cost
unmatched = filled_a - filled_b
unmatched_risk = unmatched * avg_price_a if unmatched > 0 else 0
results.append({
"status": "success" if profit > 0 else "unprofitable",
"filled_a": filled_a,
"filled_b": filled_b,
"matched": matched_qty,
"unmatched": unmatched,
"avg_price_a": round(avg_price_a, 4),
"avg_price_b": round(avg_price_b, 4),
"total_cost": round(total_cost, 2),
"profit": round(profit, 2),
"unmatched_risk": round(unmatched_risk, 2),
})
# Aggregate statistics
successes = [r for r in results if r["status"] == "success"]
failures = [r for r in results if r["status"] != "success"]
profits = [r["profit"] for r in results]
return {
"num_simulations": num_simulations,
"success_rate": len(successes) / num_simulations * 100,
"failure_rate": len(failures) / num_simulations * 100,
"avg_profit": round(sum(profits) / len(profits), 2) if profits else 0,
"min_profit": round(min(profits), 2) if profits else 0,
"max_profit": round(max(profits), 2) if profits else 0,
"median_profit": round(sorted(profits)[len(profits) // 2], 2) if profits else 0,
"profitable_pct": round(
sum(1 for p in profits if p > 0) / len(profits) * 100, 1
) if profits else 0,
}
# --- Demonstration ---
if __name__ == "__main__":
# Simulate order books
book_a = OrderBook([
(0.55, 200), (0.56, 300), (0.57, 500), (0.58, 1000),
])
book_b = OrderBook([
(0.40, 150), (0.41, 250), (0.42, 400), (0.43, 800),
])
sim = ArbExecutionSimulator(latency_ms=500, price_volatility=0.01)
stats = sim.simulate_execution(
book_a, book_b,
target_qty=500,
max_price_a=0.57,
max_price_b=0.43,
num_simulations=10000,
)
print("=== Execution Simulation Results ===")
for k, v in stats.items():
print(f" {k}: {v}")
16.7.5 Latency and Infrastructure
Professional arbitrageurs in traditional markets spend millions on low-latency infrastructure. In prediction markets, the bar is lower but still relevant:
- API access: Some platforms offer REST APIs; response times vary from 100ms to several seconds.
- WebSocket feeds: Real-time price updates via WebSocket are faster than polling REST APIs.
- Co-location: Not relevant for prediction markets (yet), but server location can affect API latency.
- Order pre-staging: Preparing orders in advance and triggering them quickly reduces execution time.
The latency hierarchy for prediction market platforms: 1. Polymarket: WebSocket API available, sub-second execution possible 2. Kalshi: REST API, typically 200-500ms 3. PredictIt: Web interface only (historically), slower execution
16.7.6 Capital Requirements and Funding
Cross-platform arbitrage requires pre-funded accounts on multiple platforms. Consider:
- Minimum deposits: Some platforms have minimum deposit requirements
- Funding methods: Bank transfer (slow, free), credit card (fast, fees), crypto (medium, gas fees)
- Currency risk: If platforms operate in different currencies, exchange rate fluctuations add risk
- KYC/AML: Account setup may take days or weeks for verification
16.8 Risks in "Risk-Free" Trades
Every experienced trader knows the saying: "There is no such thing as a free lunch." Even arbitrage, the closest thing to a free lunch in trading, comes with risks that can — and do — cause losses. This section catalogs those risks.
16.8.1 Settlement Risk
The winning platform must actually pay you. Settlement risk encompasses:
- Platform insolvency: If the platform goes bankrupt before settlement, you may not receive your payout. This is not hypothetical — several crypto prediction markets have failed.
- Delayed settlement: The platform may take days or weeks to resolve a market, during which your capital is locked.
- Disputed resolution: The market may be resolved in a way you did not expect, especially for ambiguous events.
16.8.2 Counterparty Risk
On peer-to-peer platforms, your counterparty may default. On centralized platforms, the platform itself is your counterparty. Evaluate:
- Is the platform regulated?
- Does it hold customer funds in segregated accounts?
- What is its track record for paying out?
- Is there deposit insurance or a guarantee fund?
16.8.3 Regulatory Risk
Prediction markets operate in a complex and evolving regulatory environment:
- A platform may be shut down by regulators before your market resolves
- New regulations may change the tax treatment of your profits
- Cross-border trading may create legal complications
- Position limits may be imposed retroactively
16.8.4 Resolution Ambiguity
This is perhaps the most dangerous risk in cross-platform arbitrage. Two platforms may resolve the "same" event differently due to:
- Different definitions of the event outcome
- Different sources of truth (AP call vs. official certification vs. inauguration)
- Different handling of edge cases (ties, recounts, disqualifications)
- Different "N/A" policies
Real-world example: In the 2020 US presidential election, some markets resolved based on the AP call, others waited for the Electoral College vote, and still others waited for inauguration. If a legal challenge had succeeded in changing the outcome between any of these milestones, cross-platform arbitrageurs could have faced losses on both sides.
16.8.5 Capital Lock-Up Cost
Even when the arbitrage succeeds, the capital is locked until settlement. This has a real cost:
$$C_{lockup} = K \cdot r \cdot \frac{T}{365}$$
where $K$ is the capital deployed, $r$ is your opportunity cost of capital (what you could earn elsewhere), and $T$ is the days until settlement.
If you deploy $10,000 for 6 months and your opportunity cost is 8% per year, the lock-up cost is:
$$C_{lockup} = 10000 \times 0.08 \times \frac{182}{365} = \$398.90$$
Your arbitrage profit must exceed $398.90 just to break even on an opportunity cost basis.
16.8.6 Opportunity Cost
Beyond the financial opportunity cost, there is a strategic opportunity cost. Capital locked in a low-return arbitrage cannot be deployed for potentially higher-return directional trades or other arbitrage opportunities that may arise.
16.8.7 Tax Implications
Arbitrage profits are taxable income. Depending on your jurisdiction, the tax treatment may differ between platforms (especially crypto vs. fiat platforms). Consult a tax professional, and factor estimated taxes into your return calculations.
16.8.8 Risk Assessment Checklist
Before entering any arbitrage trade, evaluate:
| Risk Factor | Assessment | Mitigation |
|---|---|---|
| Settlement risk | Platform reputation, regulation | Limit exposure per platform |
| Resolution ambiguity | Read both sets of rules carefully | Skip if rules differ materially |
| Execution risk | API reliability, liquidity depth | Start with liquid markets |
| Capital lock-up | Days to settlement, opportunity cost | Prefer short-dated markets |
| Regulatory risk | Jurisdictional analysis | Stay within regulated platforms |
| Counterparty risk | Platform financial health | Diversify across platforms |
16.9 Building an Arbitrage Bot
This section provides the architecture and code for a complete arbitrage bot. The bot monitors multiple platforms, detects opportunities, evaluates them, and executes trades. This is a skeleton implementation — a production system would require additional error handling, logging, and platform-specific API integration.
16.9.1 Architecture Overview
┌────────────────────────────────────────────────┐
│ ARBITRAGE BOT │
├────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Data Fetcher │ │ Data Fetcher │ ... │
│ │ (Platform A) │ │ (Platform B) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ Opportunity Detector │ │
│ │ - Within-platform scan │ │
│ │ - Cross-platform scan │ │
│ │ - Related-market scan │ │
│ └──────────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ Risk Evaluator │ │
│ │ - Fee calculation │ │
│ │ - Position sizing │ │
│ │ - Risk checks │ │
│ └──────────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ Execution Engine │ │
│ │ - Order placement │ │
│ │ - Fill monitoring │ │
│ │ - Error handling │ │
│ └──────────────┬───────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ Logger / Monitor │ │
│ │ - Trade log │ │
│ │ - P&L tracking │ │
│ │ - Alerts │ │
│ └──────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────┘
16.9.2 Bot Implementation
"""
Arbitrage bot skeleton for prediction markets.
This module provides a complete but simplified arbitrage bot architecture.
A production system would need platform-specific API integrations,
robust error handling, and proper async execution.
"""
import json
import logging
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, date
from enum import Enum
# ── Configuration ──────────────────────────────────────────
@dataclass
class BotConfig:
"""Configuration for the arbitrage bot."""
min_profit_pct: float = 1.0 # Minimum return to act (%)
max_position_usd: float = 1000.0 # Max position per trade
max_platform_exposure: float = 5000.0 # Max total exposure per platform
scan_interval_seconds: float = 10.0 # How often to scan
dry_run: bool = True # If True, log but do not execute
log_file: str = "arb_bot.log"
# ── Data Models ────────────────────────────────────────────
class OrderSide(Enum):
YES = "YES"
NO = "NO"
class OrderStatus(Enum):
PENDING = "PENDING"
FILLED = "FILLED"
PARTIAL = "PARTIAL"
CANCELLED = "CANCELLED"
FAILED = "FAILED"
@dataclass
class MarketData:
"""Standardized market data from any platform."""
event_id: str
platform: str
question: str
yes_ask: float
yes_bid: float
no_ask: float
no_bid: float
yes_ask_size: int
no_ask_size: int
last_updated: datetime
resolution_date: date = None
fee_rate: float = 0.0
per_contract_fee: float = 0.0
settlement_fee: float = 0.0
@dataclass
class Order:
"""An order to be placed or that has been placed."""
order_id: str
platform: str
event_id: str
side: OrderSide
price: float
quantity: int
status: OrderStatus = OrderStatus.PENDING
filled_qty: int = 0
filled_price: float = 0.0
timestamp: datetime = None
@dataclass
class ArbOpportunity:
"""A detected arbitrage opportunity."""
opp_id: str
event_id: str
opp_type: str # "within_platform", "cross_platform", "related_market"
leg_a: dict
leg_b: dict
guaranteed_profit: float
return_pct: float
max_quantity: int
detected_at: datetime
annualized_return: float = 0.0
risk_score: float = 0.0
@dataclass
class TradeLog:
"""Record of an executed arbitrage trade."""
trade_id: str
opportunity: ArbOpportunity
orders: list
status: str
realized_profit: float
timestamp: datetime
notes: str = ""
# ── Platform Adapter (Abstract) ───────────────────────────
class PlatformAdapter(ABC):
"""Abstract interface for platform-specific API interactions."""
@abstractmethod
def get_markets(self) -> list:
"""Fetch all active markets from the platform."""
pass
@abstractmethod
def get_market_data(self, event_id: str) -> MarketData:
"""Fetch current market data for a specific event."""
pass
@abstractmethod
def place_order(self, event_id: str, side: OrderSide,
price: float, quantity: int) -> Order:
"""Place an order on the platform."""
pass
@abstractmethod
def get_order_status(self, order_id: str) -> Order:
"""Check the status of an existing order."""
pass
@abstractmethod
def cancel_order(self, order_id: str) -> bool:
"""Cancel an existing order."""
pass
@abstractmethod
def get_balance(self) -> float:
"""Get available account balance."""
pass
# ── Simulated Platform Adapter ────────────────────────────
class SimulatedPlatform(PlatformAdapter):
"""A simulated platform for testing the bot."""
def __init__(self, name: str, markets: dict, balance: float = 10000.0):
self.name = name
self._markets = markets # event_id -> MarketData
self._balance = balance
self._orders = {}
self._order_counter = 0
def get_markets(self) -> list:
return list(self._markets.values())
def get_market_data(self, event_id: str) -> MarketData:
return self._markets.get(event_id)
def place_order(self, event_id: str, side: OrderSide,
price: float, quantity: int) -> Order:
self._order_counter += 1
order_id = f"{self.name}-{self._order_counter}"
cost = price * quantity
if cost > self._balance:
return Order(order_id, self.name, event_id, side, price, quantity,
OrderStatus.FAILED, 0, 0, datetime.now())
self._balance -= cost
order = Order(order_id, self.name, event_id, side, price, quantity,
OrderStatus.FILLED, quantity, price, datetime.now())
self._orders[order_id] = order
return order
def get_order_status(self, order_id: str) -> Order:
return self._orders.get(order_id)
def cancel_order(self, order_id: str) -> bool:
if order_id in self._orders:
self._orders[order_id].status = OrderStatus.CANCELLED
return True
return False
def get_balance(self) -> float:
return self._balance
# ── Opportunity Detector ──────────────────────────────────
class OpportunityDetector:
"""Detects arbitrage opportunities across platforms."""
def __init__(self, min_profit_pct: float = 1.0):
self.min_profit_pct = min_profit_pct
self._opp_counter = 0
def scan_cross_platform(self, all_data: dict) -> list:
"""
Scan for cross-platform arbitrage.
Args:
all_data: {event_id: [MarketData from different platforms]}
Returns:
List of ArbOpportunity objects.
"""
opportunities = []
for event_id, listings in all_data.items():
if len(listings) < 2:
continue
for i in range(len(listings)):
for j in range(i + 1, len(listings)):
a = listings[i]
b = listings[j]
# Strategy 1: YES on A, NO on B
opp = self._evaluate_pair(event_id, a, b, "YES_A_NO_B")
if opp:
opportunities.append(opp)
# Strategy 2: YES on B, NO on A
opp = self._evaluate_pair(event_id, b, a, "YES_A_NO_B")
if opp:
opportunities.append(opp)
return opportunities
def scan_within_platform(self, all_data: dict) -> list:
"""Scan for within-platform arbitrage."""
opportunities = []
for event_id, listings in all_data.items():
for mkt in listings:
total = mkt.yes_ask + mkt.no_ask
if total < 1.0:
gross = 1.0 - total
fee = max(1.0 - mkt.yes_ask, 1.0 - mkt.no_ask) * mkt.settlement_fee
net = gross - fee - (mkt.per_contract_fee * 2)
if net > 0 and (net / total * 100) >= self.min_profit_pct:
self._opp_counter += 1
opp = ArbOpportunity(
opp_id=f"OPP-{self._opp_counter}",
event_id=event_id,
opp_type="within_platform",
leg_a={"platform": mkt.platform, "side": "YES",
"price": mkt.yes_ask},
leg_b={"platform": mkt.platform, "side": "NO",
"price": mkt.no_ask},
guaranteed_profit=round(net, 4),
return_pct=round(net / total * 100, 2),
max_quantity=min(mkt.yes_ask_size, mkt.no_ask_size),
detected_at=datetime.now(),
)
opportunities.append(opp)
return opportunities
def _evaluate_pair(self, event_id: str, yes_mkt: MarketData,
no_mkt: MarketData, strategy: str) -> ArbOpportunity:
"""Evaluate a specific cross-platform pair."""
cost_yes = yes_mkt.yes_ask * (1 + yes_mkt.fee_rate) + yes_mkt.per_contract_fee
cost_no = no_mkt.no_ask * (1 + no_mkt.fee_rate) + no_mkt.per_contract_fee
total = cost_yes + cost_no
if total >= 1.0:
return None
profit_yes_wins = 1.0 - yes_mkt.settlement_fee * (1.0 - yes_mkt.yes_ask)
profit_no_wins = 1.0 - no_mkt.settlement_fee * (1.0 - no_mkt.no_ask)
net_yes = profit_yes_wins - total
net_no = profit_no_wins - total
guaranteed = min(net_yes, net_no)
if guaranteed <= 0 or (guaranteed / total * 100) < self.min_profit_pct:
return None
self._opp_counter += 1
return ArbOpportunity(
opp_id=f"OPP-{self._opp_counter}",
event_id=event_id,
opp_type="cross_platform",
leg_a={"platform": yes_mkt.platform, "side": "YES",
"price": yes_mkt.yes_ask},
leg_b={"platform": no_mkt.platform, "side": "NO",
"price": no_mkt.no_ask},
guaranteed_profit=round(guaranteed, 4),
return_pct=round(guaranteed / total * 100, 2),
max_quantity=min(yes_mkt.yes_ask_size, no_mkt.no_ask_size),
detected_at=datetime.now(),
)
# ── Risk Evaluator ────────────────────────────────────────
class RiskEvaluator:
"""Evaluates risk and sizes positions for arbitrage trades."""
def __init__(self, config: BotConfig):
self.config = config
self.platform_exposure: dict = {} # platform -> current exposure
def evaluate(self, opp: ArbOpportunity) -> dict:
"""
Evaluate an opportunity and return sizing recommendation.
Returns:
Dictionary with recommendation and sized quantity.
"""
# Check minimum profitability
if opp.return_pct < self.config.min_profit_pct:
return {"action": "SKIP", "reason": "Below min profit threshold"}
# Check platform exposure limits
for leg in [opp.leg_a, opp.leg_b]:
plat = leg["platform"]
current = self.platform_exposure.get(plat, 0)
if current >= self.config.max_platform_exposure:
return {"action": "SKIP",
"reason": f"Platform {plat} exposure limit reached"}
# Calculate position size
max_by_profit = int(self.config.max_position_usd /
(opp.leg_a["price"] + opp.leg_b["price"]))
max_by_depth = opp.max_quantity
qty = min(max_by_profit, max_by_depth)
if qty <= 0:
return {"action": "SKIP", "reason": "Insufficient size"}
# Risk score (0-10, lower is better)
risk_score = 0
if opp.opp_type == "cross_platform":
risk_score += 3 # Resolution risk
if opp.max_quantity < 100:
risk_score += 2 # Low liquidity
if opp.return_pct < 2.0:
risk_score += 2 # Thin margin
return {
"action": "EXECUTE",
"quantity": qty,
"risk_score": risk_score,
"total_cost": round(qty * (opp.leg_a["price"] + opp.leg_b["price"]), 2),
"expected_profit": round(qty * opp.guaranteed_profit, 2),
}
def update_exposure(self, platform: str, amount: float):
"""Update tracked exposure for a platform."""
self.platform_exposure[platform] = self.platform_exposure.get(platform, 0) + amount
# ── Execution Engine ──────────────────────────────────────
class ExecutionEngine:
"""Handles order execution for arbitrage trades."""
def __init__(self, platforms: dict, dry_run: bool = True):
self.platforms = platforms # platform_name -> PlatformAdapter
self.dry_run = dry_run
self.trade_log: list = []
self._trade_counter = 0
def execute(self, opp: ArbOpportunity, quantity: int) -> TradeLog:
"""
Execute an arbitrage trade.
Places orders for both legs and monitors fills.
"""
self._trade_counter += 1
trade_id = f"TRADE-{self._trade_counter}"
if self.dry_run:
log_entry = TradeLog(
trade_id=trade_id,
opportunity=opp,
orders=[],
status="DRY_RUN",
realized_profit=opp.guaranteed_profit * quantity,
timestamp=datetime.now(),
notes=f"Dry run: would buy {quantity} pairs",
)
self.trade_log.append(log_entry)
return log_entry
orders = []
# Execute leg A (YES side)
plat_a_name = opp.leg_a["platform"]
plat_a = self.platforms.get(plat_a_name)
if plat_a is None:
return TradeLog(trade_id, opp, [], "FAILED", 0,
datetime.now(), f"Platform {plat_a_name} not connected")
order_a = plat_a.place_order(
opp.event_id,
OrderSide(opp.leg_a["side"]),
opp.leg_a["price"],
quantity,
)
orders.append(order_a)
if order_a.status == OrderStatus.FAILED:
return TradeLog(trade_id, opp, orders, "FAILED", 0,
datetime.now(), "Leg A order failed")
# Execute leg B (NO side)
plat_b_name = opp.leg_b["platform"]
plat_b = self.platforms.get(plat_b_name)
if plat_b is None:
return TradeLog(trade_id, opp, orders, "PARTIAL", 0,
datetime.now(), f"Platform {plat_b_name} not connected")
order_b = plat_b.place_order(
opp.event_id,
OrderSide(opp.leg_b["side"]),
opp.leg_b["price"],
quantity,
)
orders.append(order_b)
if order_b.status == OrderStatus.FAILED:
# Try to cancel leg A
plat_a.cancel_order(order_a.order_id)
return TradeLog(trade_id, opp, orders, "FAILED", 0,
datetime.now(), "Leg B failed, cancelled leg A")
# Both legs filled
matched = min(order_a.filled_qty, order_b.filled_qty)
profit = matched * opp.guaranteed_profit
log_entry = TradeLog(
trade_id=trade_id,
opportunity=opp,
orders=orders,
status="SUCCESS",
realized_profit=round(profit, 4),
timestamp=datetime.now(),
notes=f"Filled {matched} pairs",
)
self.trade_log.append(log_entry)
return log_entry
# ── Main Bot ──────────────────────────────────────────────
class ArbitrageBot:
"""Main arbitrage bot orchestrator."""
def __init__(self, config: BotConfig, platforms: dict):
self.config = config
self.platforms = platforms
self.detector = OpportunityDetector(config.min_profit_pct)
self.risk_eval = RiskEvaluator(config)
self.executor = ExecutionEngine(platforms, config.dry_run)
self.logger = self._setup_logger()
def _setup_logger(self) -> logging.Logger:
logger = logging.getLogger("ArbBot")
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s"
)
handler.setFormatter(formatter)
if not logger.handlers:
logger.addHandler(handler)
return logger
def fetch_all_data(self) -> dict:
"""Fetch market data from all platforms and group by event."""
all_data = {}
for name, platform in self.platforms.items():
try:
markets = platform.get_markets()
for mkt in markets:
if mkt.event_id not in all_data:
all_data[mkt.event_id] = []
all_data[mkt.event_id].append(mkt)
except Exception as e:
self.logger.error(f"Error fetching from {name}: {e}")
return all_data
def run_once(self) -> list:
"""Run one scan-detect-evaluate-execute cycle."""
self.logger.info("Starting scan cycle...")
# 1. Fetch data
all_data = self.fetch_all_data()
self.logger.info(f"Fetched data for {len(all_data)} events")
# 2. Detect opportunities
within_opps = self.detector.scan_within_platform(all_data)
cross_opps = self.detector.scan_cross_platform(all_data)
all_opps = within_opps + cross_opps
self.logger.info(
f"Found {len(within_opps)} within-platform and "
f"{len(cross_opps)} cross-platform opportunities"
)
# 3. Evaluate and execute
results = []
for opp in sorted(all_opps, key=lambda o: o.return_pct, reverse=True):
evaluation = self.risk_eval.evaluate(opp)
if evaluation["action"] == "SKIP":
self.logger.info(
f"Skipping {opp.opp_id}: {evaluation['reason']}"
)
continue
self.logger.info(
f"Executing {opp.opp_id}: {opp.return_pct}% return, "
f"qty={evaluation['quantity']}"
)
trade = self.executor.execute(opp, evaluation["quantity"])
results.append(trade)
self.logger.info(
f"Trade {trade.trade_id}: {trade.status}, "
f"profit=${trade.realized_profit:.2f}"
)
# Update exposure tracking
if trade.status in ("SUCCESS", "DRY_RUN"):
self.risk_eval.update_exposure(
opp.leg_a["platform"], evaluation["total_cost"] / 2
)
self.risk_eval.update_exposure(
opp.leg_b["platform"], evaluation["total_cost"] / 2
)
return results
def run(self, max_cycles: int = None):
"""Run the bot continuously or for a set number of cycles."""
self.logger.info("Arbitrage bot started")
cycle = 0
try:
while max_cycles is None or cycle < max_cycles:
self.run_once()
cycle += 1
if max_cycles is None or cycle < max_cycles:
time.sleep(self.config.scan_interval_seconds)
except KeyboardInterrupt:
self.logger.info("Bot stopped by user")
total_profit = sum(t.realized_profit for t in self.executor.trade_log)
self.logger.info(
f"Bot finished. {len(self.executor.trade_log)} trades, "
f"total profit: ${total_profit:.2f}"
)
def get_summary(self) -> dict:
"""Get a summary of the bot's activity."""
trades = self.executor.trade_log
return {
"total_trades": len(trades),
"successful": sum(1 for t in trades if t.status in ("SUCCESS", "DRY_RUN")),
"failed": sum(1 for t in trades if t.status == "FAILED"),
"total_profit": round(sum(t.realized_profit for t in trades), 2),
"avg_profit_per_trade": round(
sum(t.realized_profit for t in trades) / len(trades), 2
) if trades else 0,
}
# ── Demo ──────────────────────────────────────────────────
if __name__ == "__main__":
# Create simulated platforms with sample data
now = datetime.now()
today = date.today()
polymarket_markets = {
"btc-100k": MarketData(
"btc-100k", "Polymarket", "Will BTC hit $100k by end of 2026?",
0.62, 0.60, 0.40, 0.38, 5000, 4000, now, date(2026, 12, 31),
fee_rate=0.01,
),
"fed-march": MarketData(
"fed-march", "Polymarket", "Will the Fed raise rates in March?",
0.30, 0.28, 0.68, 0.66, 3000, 2500, now, date(2026, 3, 31),
fee_rate=0.01,
),
}
kalshi_markets = {
"btc-100k": MarketData(
"btc-100k", "Kalshi", "Will BTC exceed $100k by Dec 31 2026?",
0.55, 0.53, 0.47, 0.45, 2000, 1800, now, date(2026, 12, 31),
per_contract_fee=0.03,
),
"fed-march": MarketData(
"fed-march", "Kalshi", "Fed rate hike in March 2026?",
0.28, 0.26, 0.70, 0.68, 1500, 1200, now, date(2026, 3, 31),
per_contract_fee=0.03,
),
}
platforms = {
"Polymarket": SimulatedPlatform("Polymarket", polymarket_markets),
"Kalshi": SimulatedPlatform("Kalshi", kalshi_markets),
}
config = BotConfig(
min_profit_pct=0.5,
max_position_usd=2000,
dry_run=True,
scan_interval_seconds=1,
)
bot = ArbitrageBot(config, platforms)
bot.run(max_cycles=3)
print("\n=== Bot Summary ===")
summary = bot.get_summary()
for k, v in summary.items():
print(f" {k}: {v}")
16.9.3 Production Considerations
The bot skeleton above demonstrates the architecture, but a production system requires:
- Robust API integration: Platform-specific adapters handling authentication, rate limiting, and error recovery.
- Database storage: Trade logs, price history, and configuration should be persisted in a database (SQLite for simple deployments, PostgreSQL for production).
- Alerting: Email, SMS, or Slack notifications for detected opportunities and executed trades.
- Monitoring dashboard: Real-time visualization of positions, P&L, and market data.
- Graceful shutdown: Proper handling of in-flight orders when the bot is stopped.
- Backtesting: Ability to run the bot against historical data to evaluate strategy performance.
- Compliance: Logging and record-keeping for tax reporting and regulatory compliance.
16.10 Advanced: Statistical Arbitrage
Statistical arbitrage (stat arb) differs from true arbitrage in a fundamental way: it is not risk-free. Instead, it relies on statistical relationships between markets to generate expected (but not guaranteed) profits over many trades.
16.10.1 The Concept
In traditional finance, statistical arbitrage involves identifying pairs of securities whose prices move together (cointegration) and trading the spread when it deviates from its historical mean. In prediction markets, the analogy is:
- Correlated markets: Two events that are likely to have correlated outcomes (e.g., "Will the Fed raise rates?" and "Will the 10-year yield exceed 5%?")
- Mean-reverting spreads: The price difference between correlated markets tends to revert to a stable relationship.
16.10.2 Pairs Trading in Prediction Markets
The pairs trading strategy adapted for prediction markets:
- Identify correlated markets: Find pairs of markets whose prices tend to move together.
- Calculate the spread: Track the difference (or ratio) of prices over time.
- Estimate the equilibrium spread: Use historical data to determine the "normal" spread.
- Trade deviations: When the spread deviates significantly from equilibrium, trade in the direction of mean reversion.
"""Statistical arbitrage (pairs trading) for prediction markets."""
import statistics
from dataclasses import dataclass
@dataclass
class PricePair:
"""A pair of prices for two correlated markets at a point in time."""
timestamp: str
price_a: float
price_b: float
@property
def spread(self) -> float:
return self.price_a - self.price_b
@property
def ratio(self) -> float:
return self.price_a / self.price_b if self.price_b != 0 else float("inf")
class StatArbAnalyzer:
"""Analyzes pairs of prediction markets for statistical arbitrage."""
def __init__(self, lookback: int = 50, entry_z: float = 2.0,
exit_z: float = 0.5):
self.lookback = lookback
self.entry_z = entry_z
self.exit_z = exit_z
def calculate_spread_stats(self, history: list) -> dict:
"""
Calculate spread statistics for a pair of markets.
Args:
history: List of PricePair objects.
Returns:
Dictionary with spread statistics.
"""
if len(history) < self.lookback:
return {"error": "Insufficient history"}
recent = history[-self.lookback:]
spreads = [p.spread for p in recent]
mean_spread = statistics.mean(spreads)
std_spread = statistics.stdev(spreads)
current_spread = spreads[-1]
z_score = ((current_spread - mean_spread) / std_spread
if std_spread > 0 else 0)
# Calculate correlation
prices_a = [p.price_a for p in recent]
prices_b = [p.price_b for p in recent]
correlation = self._correlation(prices_a, prices_b)
return {
"mean_spread": round(mean_spread, 4),
"std_spread": round(std_spread, 4),
"current_spread": round(current_spread, 4),
"z_score": round(z_score, 2),
"correlation": round(correlation, 4),
"half_life": self._estimate_half_life(spreads),
}
def generate_signal(self, stats: dict) -> dict:
"""
Generate a trading signal based on spread statistics.
Args:
stats: Output from calculate_spread_stats.
Returns:
Signal dictionary with action and details.
"""
if "error" in stats:
return {"action": "WAIT", "reason": stats["error"]}
z = stats["z_score"]
corr = stats["correlation"]
# Only trade if correlation is strong enough
if abs(corr) < 0.5:
return {"action": "WAIT", "reason": "Weak correlation"}
if z > self.entry_z:
return {
"action": "SELL_A_BUY_B",
"z_score": z,
"reason": "Spread above upper threshold — expect reversion",
"confidence": min(abs(z) / 3.0, 1.0),
}
elif z < -self.entry_z:
return {
"action": "BUY_A_SELL_B",
"z_score": z,
"reason": "Spread below lower threshold — expect reversion",
"confidence": min(abs(z) / 3.0, 1.0),
}
elif abs(z) < self.exit_z:
return {
"action": "CLOSE",
"z_score": z,
"reason": "Spread near mean — close positions",
"confidence": 1.0,
}
else:
return {
"action": "HOLD",
"z_score": z,
"reason": "Spread within normal range",
}
def backtest(self, history: list, position_size: float = 100.0) -> dict:
"""
Backtest the pairs trading strategy on historical data.
Args:
history: Full price history as list of PricePair.
position_size: Dollar amount per trade.
Returns:
Backtest results.
"""
if len(history) < self.lookback + 10:
return {"error": "Insufficient history for backtest"}
trades = []
position = None # None, "LONG_A_SHORT_B", "SHORT_A_LONG_B"
entry_spread = 0
for i in range(self.lookback, len(history)):
window = history[i - self.lookback:i + 1]
stats = self.calculate_spread_stats(window)
if "error" in stats:
continue
signal = self.generate_signal(stats)
current_spread = stats["current_spread"]
if position is None:
if signal["action"] == "BUY_A_SELL_B":
position = "LONG_A_SHORT_B"
entry_spread = current_spread
elif signal["action"] == "SELL_A_BUY_B":
position = "SHORT_A_LONG_B"
entry_spread = current_spread
else:
if signal["action"] == "CLOSE" or signal["action"] in (
"BUY_A_SELL_B", "SELL_A_BUY_B"
):
# Close position
if position == "LONG_A_SHORT_B":
pnl = (current_spread - entry_spread) * position_size
else:
pnl = (entry_spread - current_spread) * position_size
trades.append({
"entry_spread": round(entry_spread, 4),
"exit_spread": round(current_spread, 4),
"position": position,
"pnl": round(pnl, 2),
})
position = None
if not trades:
return {"total_trades": 0, "note": "No trades triggered"}
pnls = [t["pnl"] for t in trades]
winners = [p for p in pnls if p > 0]
losers = [p for p in pnls if p <= 0]
return {
"total_trades": len(trades),
"winners": len(winners),
"losers": len(losers),
"win_rate": round(len(winners) / len(trades) * 100, 1),
"total_pnl": round(sum(pnls), 2),
"avg_pnl": round(statistics.mean(pnls), 2),
"max_win": round(max(pnls), 2) if pnls else 0,
"max_loss": round(min(pnls), 2) if pnls else 0,
"sharpe_approx": round(
statistics.mean(pnls) / statistics.stdev(pnls), 2
) if len(pnls) > 1 and statistics.stdev(pnls) > 0 else 0,
}
@staticmethod
def _correlation(x: list, y: list) -> float:
"""Calculate Pearson correlation between two lists."""
n = len(x)
if n < 2:
return 0
mean_x = sum(x) / n
mean_y = sum(y) / n
cov = sum((x[i] - mean_x) * (y[i] - mean_y) for i in range(n)) / (n - 1)
std_x = (sum((xi - mean_x) ** 2 for xi in x) / (n - 1)) ** 0.5
std_y = (sum((yi - mean_y) ** 2 for yi in y) / (n - 1)) ** 0.5
if std_x == 0 or std_y == 0:
return 0
return cov / (std_x * std_y)
@staticmethod
def _estimate_half_life(spreads: list) -> float:
"""Estimate the half-life of mean reversion using OLS."""
if len(spreads) < 3:
return float("inf")
# Simple AR(1) regression: spread_t = alpha + beta * spread_{t-1}
y = spreads[1:]
x = spreads[:-1]
n = len(y)
mean_x = sum(x) / n
mean_y = sum(y) / n
num = sum((x[i] - mean_x) * (y[i] - mean_y) for i in range(n))
den = sum((x[i] - mean_x) ** 2 for i in range(n))
beta = num / den if den != 0 else 1
# half_life = -ln(2) / ln(beta), but beta must be in (0, 1)
import math
if 0 < beta < 1:
return round(-math.log(2) / math.log(beta), 1)
return float("inf")
# --- Demonstration ---
if __name__ == "__main__":
import random
random.seed(42)
# Generate synthetic correlated price data
history = []
price_a = 0.50
price_b = 0.48
for i in range(200):
shock = random.gauss(0, 0.02)
idio_a = random.gauss(0, 0.005)
idio_b = random.gauss(0, 0.005)
price_a = max(0.05, min(0.95, price_a + shock + idio_a))
price_b = max(0.05, min(0.95, price_b + shock + idio_b))
history.append(PricePair(f"T{i}", round(price_a, 4), round(price_b, 4)))
analyzer = StatArbAnalyzer(lookback=50, entry_z=2.0, exit_z=0.5)
stats = analyzer.calculate_spread_stats(history)
print("=== Spread Statistics ===")
for k, v in stats.items():
print(f" {k}: {v}")
signal = analyzer.generate_signal(stats)
print("\n=== Current Signal ===")
for k, v in signal.items():
print(f" {k}: {v}")
bt = analyzer.backtest(history)
print("\n=== Backtest Results ===")
for k, v in bt.items():
print(f" {k}: {v}")
16.10.3 Risks of Statistical Arbitrage
Statistical arbitrage in prediction markets carries risks beyond those of true arbitrage:
- Regime changes: The correlation between two markets may break down, especially around major events.
- Model risk: Your statistical model may be wrong — the spread may not be mean-reverting.
- Liquidity risk: You may not be able to exit the position at the expected price.
- Convergence failure: Unlike true arbitrage where convergence is guaranteed, statistical relationships can diverge permanently.
- Overfitting: If you optimize parameters on historical data, performance may not generalize.
The key difference from true arbitrage: you can lose money on any individual trade. Statistical arbitrage is a portfolio strategy that aims to be profitable in aggregate, not on every trade.
16.11 Chapter Summary
This chapter provided a comprehensive treatment of arbitrage in prediction markets. Let us review the key concepts:
Types of Arbitrage: - Within-platform: Prices on a single platform that violate no-arbitrage conditions (YES + NO < $1.00, or multi-outcome sum < $1.00). - Cross-platform: The same event priced differently on different platforms, allowing risk-free profit by taking opposite sides. - Temporal: Exploiting delayed price adjustments across time, often driven by information asymmetry or liquidity gaps. - Related-market: Exploiting logical inconsistencies between markets for related events (subset/superset violations, sequential constraints, conditional probability violations). - Statistical: Not truly risk-free, but statistically profitable strategies based on mean-reverting spreads between correlated markets.
The Arbitrage Calculation: 1. Identify the opportunity and catalog all prices, fees, and resolution criteria. 2. Calculate fee-adjusted profit for every possible outcome. 3. The guaranteed profit is the minimum across all outcomes. 4. Size the position based on capital, liquidity, and position limits. 5. Calculate ROI and annualized return, accounting for time to settlement.
Execution Challenges: - Simultaneous execution is difficult in practice. - Partial fills, slippage, and latency can erode or eliminate profits. - Cross-platform execution adds complexity from different APIs, funding methods, and interfaces.
Risks in "Risk-Free" Trades: - Settlement risk (platform failure or delayed resolution) - Resolution ambiguity (different platforms may resolve differently) - Capital lock-up cost (opportunity cost of tied-up capital) - Regulatory risk (platform shutdowns, rule changes) - Counterparty risk (especially on less established platforms)
Building Automation: - A systematic approach with data collection, opportunity detection, risk evaluation, and execution can scale arbitrage activity. - Production systems require robust error handling, database storage, monitoring, and compliance logging.
The fundamental lesson of this chapter is that arbitrage in prediction markets is more accessible than in traditional financial markets, but it is rarely as "risk-free" as it appears on paper. Success requires rigorous calculation, careful execution, and honest assessment of the risks involved.
What's Next
In Chapter 17: Market Making Strategies, we shift from taking arbitrage opportunities to providing liquidity. Market makers earn the bid-ask spread by continuously quoting both sides of a market. We will explore how to set quotes, manage inventory risk, and build an automated market maker — turning the skills developed in this chapter (price monitoring, execution, and risk management) into a continuous source of income. Where the arbitrageur waits for mispricing, the market maker creates the prices that keep the market efficient.