Polymarket Position Sizing With Fee-Adjusted Kelly
>This covers fee-adjusted Kelly sizing. Polymarket Profits 2 builds it into a full 7-bot portfolio with correlation-aware allocation and 30-day drawdown caps.

Summary:
- Compute quarter-Kelly position sizes that survive real Polymarket fees and slippage.
- Fix the 68% win-rate trap with a one-line stop-loss and a correlation discount.
- Copy-paste a
kelly_size()function that reads fees from the Gamma API.- Know when Kelly says skip the trade, which is most of the time if you are honest about your edge.
“Bot wins 68% of the time and still loses money.” It is the single most common story in Polymarket trading communities: paper-trading results look great, live trading bleeds out over a few hundred trades, the owner blames fees or rigged markets. Both are wrong. The problem is always position sizing, and Kelly fixes it when you size for Polymarket’s actual fee structure.
I ran the Kelly formula on every one of the seven bots in my Polymarket stack. The first version overbet on every trade because I plugged gross spreads into the formula instead of net payouts. Lost about $180 before I figured out the formula needed real fee math baked in.
Why does a 68% win rate still lose money?

A 68% win rate loses money when each loss is 2.75× larger than each win. Expected value is the one number that decides profitability, not win rate.
Expected value is your average profit per trade over a large number of trades:
EV = (win_rate × avg_win) + (loss_rate × avg_loss)
Here is an illustrative case. Say a bot wins 68% of trades averaging +$8.50, and loses 32% averaging −$23.40 because it holds losing positions all the way to resolution at $0.00. Plug the numbers in:
EV = (0.68 × 8.50) + (0.32 × -23.40)
= 5.78 − 7.49
= -$1.71 per trade
Negative $1.71 per trade across 200 trades is −$342. A bot with no stop-loss grinds the account down even though the win rate looks great. Kelly sizing assumes you have a stop-loss.
Add a 15% stop-loss and the same bot flips profitable. Average loss drops from $23.40 to $9.30:
EV = (0.68 × 8.50) + (0.32 × -9.30)
= 5.78 − 2.98
= +$2.80 per trade
Same win rate, same markets, one extra line of code. Over 200 trades that is +$560 instead of −$342. The stop-loss is the prerequisite for any Kelly sizing to work. Without it, you are sizing a strategy that has no downside control, and the math breaks.
What is Kelly for a binary Polymarket trade?
Kelly criterion is a position-sizing formula that maximizes the expected log of your bankroll. For a binary YES/NO Polymarket trade the formula simplifies to:
Kelly % = (win_prob × net_payout − loss_prob) / net_payout
where net_payout is the return per dollar if the trade wins, after fees and slippage. Skip the costs and Kelly overbets. Use the gross spread and you size bigger than the math supports, and your equity curve grinds down even on positive-EV trades.
Quarter-Kelly is what experienced bot operators actually run. Divide the full Kelly number by 4. That absorbs up to 25% estimation error in your win probability and cuts typical drawdowns by roughly three quarters. Your 60% estimate is almost never exactly 60%, and full Kelly is asymmetrically brutal when you overestimate.
What broke when I first built this?
My first Kelly function computed size against the gross spread and ignored Polymarket’s category fees. On crypto markets, that is a huge error.
The real Polymarket fee formula has a shape most tutorials miss:
fee = shares × feeRate × price × (1 − price)
Notice the price × (1 − price) term. The fee peaks at p = 0.50 and drops toward zero at the extremes. On a crypto market with feeRateBps = 720, a 500-share trade at $0.30 carries a $7.56 fee. That is about 5% of trade value, not the “0–2%” most tutorials quote. Plug a 3% gross edge into full Kelly and you get a position size that goes negative-log-growth the second the fee hits.
Here is the fix: a kelly_size() function that takes the real fee rate as a parameter, subtracts fees and slippage from the payout, and returns a quarter-Kelly dollar amount.
def kelly_size(bankroll, win_prob, entry_price,
fee_rate_bps=720, slippage_estimate=0.005,
payout=1.0, fraction=0.25):
"""Quarter-Kelly position size in dollars.
bankroll Total capital you are sizing against.
win_prob Your probability estimate the trade wins.
entry_price The YES price you plan to buy at.
fee_rate_bps Polymarket fee rate for this market category.
Default is crypto (720 bps) as worst case.
Fetch the real value with get_fee_rate_bps().
slippage_estimate Per-share slippage in dollars. Use the
depth-aware estimate from your scanner.
payout Expected payout per share. 1.0 = hold to
resolution. Set lower for stop-loss exits.
fraction 0.25 = quarter-Kelly (default). 0.50 = half.
1.0 = full Kelly (not recommended).
Note: models the resolution path (shares pay $1 at settlement).
If you exit via stop-loss, compute payout as the expected
exit price minus entry fee, exit fee, and exit slippage.
"""
fee_rate = fee_rate_bps / 10000
per_share_fee = fee_rate * entry_price * (1 - entry_price)
net_payout = (
payout - entry_price - per_share_fee - slippage_estimate
) / entry_price
if net_payout <= 0:
return 0 # Costs eat the entire payout. Skip.
kelly = (win_prob * net_payout - (1 - win_prob)) / net_payout
if kelly <= 0:
return 0 # Negative EV after costs. Skip.
return bankroll * kelly * fraction
One function. Every strategy in your stack calls it before placing a trade. Notice it takes fee_rate_bps as a parameter, not a market ID. The caller fetches the real rate and passes it in. That makes the function testable and lets you cache fee rates across many trades instead of hammering the API on every sizing call.
Here is the caller pattern. This is the line that matters most: fetch the real fee rate, and gate it behind feesEnabled so fee-free markets compute zero fee instead of the crypto default.
import requests
CLOB_BASE = "https://clob.polymarket.com"
GAMMA_BASE = "https://gamma-api.polymarket.com"
def get_fee_rate_bps(token_id, default=720):
"""Fetch fee rate in basis points from the CLOB endpoint.
CLOB /fee-rate returns {"base_fee": <int>} in basis points.
The field is named base_fee but the value IS a rate in bps.
"""
try:
resp = requests.get(
f"{CLOB_BASE}/fee-rate",
params={"token_id": token_id},
timeout=5,
)
resp.raise_for_status()
return int(resp.json().get("base_fee", default))
except Exception:
return default
def is_fees_enabled(market_id):
"""Return True if the market actually charges fees.
Some Polymarket markets have feesEnabled=false regardless
of category. A bot that skips this check will reject valid
trades on fee-free markets."""
try:
resp = requests.get(
f"{GAMMA_BASE}/markets/{market_id}",
timeout=5,
)
resp.raise_for_status()
return bool(resp.json().get("feesEnabled", False))
except Exception:
return False
def size_trade(bankroll, win_prob, entry_price, market_id, token_id):
"""Fetch fee rate, check feesEnabled, compute quarter-Kelly."""
if is_fees_enabled(market_id):
fee_bps = get_fee_rate_bps(token_id)
else:
fee_bps = 0 # Fee-free market: do not penalize
return kelly_size(
bankroll=bankroll,
win_prob=win_prob,
entry_price=entry_price,
fee_rate_bps=fee_bps,
)
Skip is_fees_enabled() and your sizing code will quietly over-penalize every fee-free market on the platform. The Gamma API returns the flag directly. One call, one branch, one correctness improvement.
How do you test Kelly on a real live market?
You pull a live market from the Gamma API and run kelly_size() with your probability estimate. The API is public, free, and returns JSON with no auth required.
From gamma-api.polymarket.com/markets/540816, here is the live market we will size:
| Field | Value |
|---|---|
| Question | Russia-Ukraine Ceasefire before GTA VI? |
| Market ID | 540816 |
| YES price | $0.535 |
| NO price | $0.465 |
| feesEnabled | false |
| Volume (total) | ~$1.47M |
| End date | 2026-07-31 |
| negRisk | false |
This market has feesEnabled: false. That means the category fee rate is irrelevant — the exchange charges nothing on this market. kelly_size() picks this up via is_fees_enabled() and sizes the trade as if the fee were zero. If you hardcode crypto 720 bps instead of checking, you will systematically under-size or reject good trades on this market.
Run the caller pattern above with your own probability estimate:
# Your estimate: 60% chance ceasefire happens first
dollar_size = size_trade(
bankroll=5000,
win_prob=0.60,
entry_price=0.535,
market_id="540816",
token_id="<yes_token_id_from_market>",
)
print(f"Quarter-Kelly size: ${dollar_size:.2f}")
Because this market has feesEnabled=false, size_trade() sets the fee rate to zero and kelly_size() receives a clean payout term. Compare that to calling kelly_size() directly with fee_rate_bps=720 (crypto default) on the same market: you would under-size the trade by pricing in a fee that doesn’t exist.
Try it with different probability estimates. At 60%, you get around 5% of bankroll. At 55%, the size drops fast. At 52%, it is essentially zero. At 50% (no edge), the function returns 0 because Kelly correctly says “do not trade.” That is the whole point of the formula. It protects you from trades that feel good but have no real edge.
Why not just use full Kelly?
Four reasons, all of them punishing if you ignore them.
1. Your probability estimate is wrong. Not slightly wrong. Often 5 to 10 points off. Full Kelly punishes overestimation asymmetrically: bet more than the math supports and your log-growth goes negative even with positive arithmetic EV. Quarter-Kelly absorbs the error.
2. Positions correlate. Five 5-minute BTC market positions are not five independent bets. They are one bet on BTC direction made five times. Full Kelly per-trade compounds into full-Kelly-on-BTC-direction across the cluster, which is way more risk than you planned.
3. Edge drifts. The spread you measured last month is not the spread you will get next month as competing bots enter. Fractional Kelly absorbs that drift without forcing you to recalibrate every week.
4. Drawdown survival. Full Kelly is mathematically optimal only if you can stomach a 50% drawdown. Almost nobody can. They quit during the drop and lock in the loss, which converts “optimal long term” into “permanent loss.”
Quarter-Kelly is what you actually run. Half-Kelly is the most aggressive ceiling I would trust with real money.
How do you handle correlated positions?
You treat any cluster of positions that share the same underlying asset and resolve in the same hour as a single Kelly-sized bet, then split that size across the cluster.
Example: your scanner wants to open five positions on 5-minute BTC thresholds that all resolve between 5:00 PM and 5:30 PM. Do not bet quarter-Kelly five times. Bet quarter-Kelly once for the BTC-direction cluster, then split that dollar amount across the five markets. A $400 quarter-Kelly becomes $80 each on five markets, not $400 each.
def cluster_kelly_size(bankroll, win_prob, entry_prices,
fee_rate_bps):
"""Split one Kelly-sized bet across N correlated markets.
Pass the fee_rate_bps for the cluster (same category, same
rate). Callers gate this behind is_fees_enabled() the same
way size_trade() does for single positions.
Returns a list of per-market dollar amounts.
"""
avg_price = sum(entry_prices) / len(entry_prices)
cluster_size = kelly_size(
bankroll=bankroll,
win_prob=win_prob,
entry_price=avg_price,
fee_rate_bps=fee_rate_bps,
)
per_market = cluster_size / len(entry_prices)
return [per_market] * len(entry_prices)
Run this anytime your bot detects multiple positions in the same asset and time window. The function treats the cluster as one logical position, and your aggregate exposure stays at quarter-Kelly instead of quietly ballooning to full.
Wire size_trade() into the paper-trading trainer from Chapter 3 of Polymarket Profits 2 before you touch real money. The trainer runs every strategy in the book against live Polymarket data with fake USDC, so you can validate that the Kelly sizes it produces match the outcomes you expected before any real capital is at risk.
Related in this series
- Polymarket Trading Fees: The Real Formula. Where the
fee_rate_bpsparameter comes from. - How to Build a Polymarket Resolution Scanner. The strategy that most needs correct Kelly sizing.
- How to Build a Contrarian Bot for Polymarket. Binary payouts make sizing the hardest part.
- How to Detect Edge Decay in Your Trading Bot. Shrink Kelly fractions when edge starts decaying.
What should you actually do?
- If you have fewer than 50 live trades in a strategy: Use fixed-fraction sizing (2% of bankroll per trade) instead of Kelly. You do not have enough data for Kelly to mean anything yet.
- If your strategy has 100+ trades with a measured win rate: Call
size_trade()(or wireget_fee_rate_bps()+is_fees_enabled()manually intokelly_size()). Verify the recommended size is under 10% of bankroll on any single trade. - If the strategy trades correlated markets: Use
cluster_kelly_size()and split one Kelly-sized bet across the cluster. Never Kelly-size each position independently. - If
kelly_size()returns 0: Skip the trade. Zero means the edge does not clear the costs. Listen to it. - If you exit via stop-loss instead of resolution: Pass a lower
payoutvalue tokelly_size()— the realized exit price minus exit fees and exit slippage, not $1.00. - If you are tempted to use full Kelly: Stop. Full Kelly only works if you know the true win probability, and you do not. Stay at quarter.
bottom_line
- A 68% win rate without a stop-loss still loses money. Add the 15% stop before you touch Kelly.
- Kelly computed against gross spreads overbets on every Polymarket trade. Use the fee-adjusted version from
kelly_size()or do not use Kelly at all. - Treat correlated positions as one cluster, bet Kelly once, split the size. Five 5-minute BTC positions are one bet, not five.
Frequently Asked Questions
How do you apply the Kelly criterion to Polymarket trades?+
Compute net payout after the real Polymarket fee formula (shares × feeRate × p × (1−p)) and slippage, plug it into the binary Kelly formula, then divide by four. Quarter-Kelly survives a 25% probability misestimation without going negative-log-growth.
Why does a 68% win rate still lose money on Polymarket?+
Because the average loss is larger than the average win. A bot with no stop-loss lets losers run to resolution at $0.00 while closing winners early. Expected value goes negative even though the win rate looks great.
Should I use full Kelly or fractional Kelly on Polymarket?+
Use quarter-Kelly as the default and half-Kelly as the aggressive ceiling. Full Kelly assumes your win-probability estimate is perfect, which it never is, and punishes any estimation error with 50%+ drawdowns.
More from this Book
How to Build a Contrarian Bot for Polymarket
A Polymarket contrarian bot that uses base rates, engagement-weighted sentiment, and a political-market insider filter. With Python code and examples.
from: Polymarket Profits 2
How to Build a Polymarket Resolution Scanner
A Polymarket resolution scanner that catches events before the price updates. Covers the 4 event types, 15-second staleness check, and edge calculation.
from: Polymarket Profits 2
Polymarket Trading Fees: The Real Formula and Table
The real Polymarket fee formula, taker rates per category from the official docs, and a worked example proving 5%+ effective fees kill most arbitrage bots.
from: Polymarket Profits 2
How to Detect Edge Decay in Your Trading Bot
Five metrics to detect edge decay in a Polymarket trading bot, plus the platform-change monitor that catches fee updates before they show up in your P&L.
from: Polymarket Profits 2