26 min read

> "Give me a place to stand, and I shall move the world." — Archimedes

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:

  1. No net capital at risk: The combined position has no downside.
  2. Guaranteed profit: The payoff is positive in every possible outcome.
  3. 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:

  1. Fetch all prices for a given market.
  2. For binary markets: check if best_ask(YES) + best_ask(NO) < 1.00 (after fees).
  3. For multi-outcome markets: check if sum of all best_ask prices < 1.00 (after fees).
  4. Calculate the fee-adjusted profit.
  5. 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:

  1. 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.
  2. Different resolution dates: One platform may resolve at year-end, another at a specific date within the year.
  3. Ambiguity in edge cases: What happens if a candidate drops out? What if the event is cancelled? Each platform has its own rules.
  4. 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:

  1. Automated systems monitoring news feeds, APIs, and social media can react in milliseconds.
  2. Active manual traders monitoring multiple sources can react in seconds to minutes.
  3. 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:

  1. Information Quality: How reliable is the new information? False signals lead to losses.
  2. Information Speed: How quickly are you processing this relative to other market participants?
  3. Price Impact: How much should the price move in response to this information?
  4. Execution Capacity: Can you execute a meaningful position before the price adjusts?
  5. 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.


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).

"""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:

  1. Domain knowledge: Understanding which events are logically connected.
  2. Careful reading: Parsing the exact resolution criteria to verify the relationship holds.
  3. 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:

  1. Robust API integration: Platform-specific adapters handling authentication, rate limiting, and error recovery.
  2. Database storage: Trade logs, price history, and configuration should be persisted in a database (SQLite for simple deployments, PostgreSQL for production).
  3. Alerting: Email, SMS, or Slack notifications for detected opportunities and executed trades.
  4. Monitoring dashboard: Real-time visualization of positions, P&L, and market data.
  5. Graceful shutdown: Proper handling of in-flight orders when the bot is stopped.
  6. Backtesting: Ability to run the bot against historical data to evaluate strategy performance.
  7. 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:

  1. Identify correlated markets: Find pairs of markets whose prices tend to move together.
  2. Calculate the spread: Track the difference (or ratio) of prices over time.
  3. Estimate the equilibrium spread: Use historical data to determine the "normal" spread.
  4. 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:

  1. Regime changes: The correlation between two markets may break down, especially around major events.
  2. Model risk: Your statistical model may be wrong — the spread may not be mean-reverting.
  3. Liquidity risk: You may not be able to exit the position at the expected price.
  4. Convergence failure: Unlike true arbitrage where convergence is guaranteed, statistical relationships can diverge permanently.
  5. 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.