Skip to content

ArbitrageStrategy: flat fee_bps understates Polymarket fees where the strategy fires #2

Description

@imkane

Hey there! I've been measuring Polymarket and Kalshi order books for a few weeks and ended up reading src/polybot/strategies/arbitrage.py closely enough that a couple of things seem worth flagging. Both concern the same strategy. I can split them into separate issues if that tracks better.

1. The fee model is flat. Polymarket's isn't.

ArbitrageConfig.fee_bps defaults to 50.0 at line 54, commented as 0.5% estimated round-trip fees. Line 284 then does this:

fee_cost = self.arb_config.fee_bps / 10000 * 2  # Round trip on both legs

Minor thing first, since it's right there: 50 bps is already documented as the round trip, and then it gets doubled again. So the effective assumption is 1%, not the 0.5% the comment describes. One of those two is probably not what you meant.

The bigger issue is that it's a constant at all. Polymarket's taker fee is shares * rate * P * (1 - P), with the rate coming from the market's Gamma feeSchedule. That's quadratic in price and peaks at P = 0.5.

For a YES/NO pair at 0.50 each, at the 0.07 rate I've seen in the wild:

fee per leg = 0.07 * 0.50 * 0.50 = 0.0175 per share
both legs   = 0.035 on a $1.00 pair, so 3.5%

Against the 1% the config assumes. At a 0.04 rate it's 2%. Either way the real cost near the midpoint runs 2 to 3.5 times the estimate, and the midpoint is exactly where two-sided depth lives, so that's where an arb scan spends most of its time.

The shape runs the other way at the extremes. YES 0.05 / NO 0.95 works out around 0.67% per pair, so a flat 1% is roughly fine out there and badly wrong in the middle. With min_price/max_price defaulting to 0.01/0.99 the strategy spans both regimes on one number.

Also worth knowing: feesEnabled is false on some markets, so the correct rate is genuinely 0 sometimes. It's per-market, not a venue constant.

2. total_cost is top of book, but shares assumes the whole size fills there.

Lines 276 and 303:

total_cost = yes_price.ask + no_price.ask
...
shares = position_size_usd / total_cost

The sum is best ask on each side, and the position size comes off that one price. If the top rung is thin, the second rung is what you actually pay for most of the fill. I hit this enough building my own scanner that I ended up walking both ladders to a common fill size and returning partial fills as partials instead of scaling them up to the target. A thin top of book is the most reliable way I know to manufacture an opportunity that isn't there.

3. The opportunity set was empty when I looked.

I scanned 56,182 evaluable Polymarket markets in August 2026, walking both ladders rather than reading top of book. Zero positives. Best ask sum across the whole scan was 1.0010, which is above parity rather than below it.

That's one snapshot and I'm not claiming it holds forever. But it lines up with the literature: Saguillo et al. (arXiv 2508.03474) measured roughly $40M of this arbitrage between April 2024 and April 2025, and the gap appears to have closed since. So if the strategy is running and quiet, that might be the reason rather than a bug.

My scan is at https://github.com/imkane/pm-kalshi-adjudication if you want to check it.

One non-issue I want to flag so you know I read the code rather than the README: I came in assuming polybot paired contracts across Polymarket and Kalshi, and it doesn't. src/polybot/mappings/models.py maps prediction-market events to hedgeable Binance instruments, and EventMapping.venue at line 94 is a single source venue defaulting to polymarket. So the cross-venue contract-matching problem I've been working on doesn't apply here the way I thought it would.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions