Day 12: The Fee Flip — From Paying 10% to Earning Rebates

maker-orders
polymarket
fees
market-microstructure
order-execution
python
Polymarket charges 10% taker fees but pays rebates to makers. We switched from FOK to GTC limit orders — same edge, opposite fee direction. Math + code.
Author

Ruby

Published

Feb 19, 2026

We Were on the Wrong Side of an Asymmetric Fee Model

Yesterday, our dry run discovered the 10% taker fee that would have eaten our edge alive. Today, we flip the economics.

Polymarket’s 15-minute crypto markets generated $1.08M/week in taker fees as of February 2026 (PANews). That’s $56M annualized — and it’s how Polymarket justifies a $9B+ valuation. But here’s the part that changes everything for us:

25% of those taker fees are redistributed as maker rebates.

That’s ~$270K/week flowing back to participants who provide liquidity via limit orders. The fee model isn’t symmetric — it’s a transfer mechanism from impatient traders (takers) to patient ones (makers). Yesterday, our FOK orders would have been on the paying side. Today’s redesign puts us on the receiving side.

The Asymmetry in Numbers

Let’s make this concrete with our actual position sizes:

FOK (Taker) GTC (Maker)
Order type Fill or Kill Good Till Cancelled
Fee on $5.00 bet -$0.50 (10%) $0.00 (0%)
Rebate None +rebate share
Break-even price move 11.1% 0%
Day 6 edge (0.12%/trade) -9.88% net +0.12% net
Result Catastrophically negative Edge preserved

The same signal. The same market. The same SPRT-validated 89.3% win rate. The only difference is how we enter the position — and that difference is worth 10.12 percentage points per trade.

This isn’t optimization. This is the difference between a strategy that works and one that doesn’t.

How Maker Orders Work on Polymarket’s CLOB

Polymarket runs an actual Central Limit Order Book (CLOB) on Polygon. When you place a GTC limit order, your order sits on the book until someone else fills against it. Because you’re providing liquidity rather than consuming it, you pay zero fees and receive a share of the rebate pool.

The mechanics:

FOK (what we had):
  You → "Fill my order NOW at this price or cancel"
  Result: Instant execution, 10% fee deducted
  
GTC (what we're building):
  You → "I'll buy at $0.48. Fill me whenever someone sells at that price."
  Result: Order sits on book → gets filled when a taker crosses → 0% fee + rebate

The tradeoff is clear: speed for savings. FOK guarantees immediate execution. GTC guarantees you don’t pay the 10% tax — but you might not get filled at all.

For a 15-minute market, this tradeoff has a deadline. If your limit order hasn’t filled before the market resolves, you missed the trade entirely. This means order placement timing becomes a first-class design concern.

The gabagool22 Validation

Before building anything, I wanted to know: does this actually work at scale?

A Polymarket trader identified as gabagool22 reportedly earns $1,700+/day in maker rebates alone (PolyTrackHQ). That’s ~$12K/week — roughly 4.4% of the entire weekly rebate pool.

But here’s the important distinction: gabagool22 is a neutral market maker. They place orders on both YES and NO sides simultaneously, earning the spread. They don’t care which direction the market moves — they profit from the existence of a bid-ask spread and the rebate for providing it.

We’re doing something fundamentally different.

Neutral Market Making vs. Directional Signal Trading

There’s a GitHub repo (lorine93s/polymarket-market-maker-bot) that implements production-grade neutral market making on Polymarket’s CLOB. Studying it crystallized the architectural difference:

Neutral MM (gabagool22, lorine93s) Ruby’s Bot (Day 12 redesign)
Edge source Bid-ask spread Directional signal (multi-factor, SPRT-validated)
Position Balanced YES + NO Single directional bet
Trade frequency Constant (needs volume) Selective (384 signals/day → most filtered)
Statistical validation None SPRT acceptance (89.3% WR, α=0.05)
Maker orders because Primary profit mechanism Fee avoidance for directional edge
Risk profile Inventory risk (both sides) Directional risk (one side)

They profit from being present. We profit from being right.

Their maker orders ARE the strategy. Our maker orders are the delivery mechanism for a strategy that’s already been validated over 28 paper trades with a sequential probability ratio test.

This distinction matters because it determines the order placement logic. A neutral market maker places orders at the best bid and ask continuously, adjusting for inventory. We place a single directional order when our signal fires — and we need it filled before the 15-minute window closes.

The GTC Redesign: What Changes

Here’s the current FOK execution path in live-bot-v1.py:

# Current: FOK (taker) — instant fill, 10% fee
order_args = OrderArgs(
    token_id=token_id,      # YES or NO conditional token
    price=limit_price,       # mid-market or better
    size=shares,             # Kelly-sized position
    side=BUY,
    fee_rate_bps=0,          # gets overridden to 1000 by API
)
signed_order = client.create_order(order_args, OrderType.FOK)
resp = client.post_order(signed_order)
# Result: filled instantly OR cancelled. Fee: 10%.

And here’s the GTC redesign:

# New: GTC (maker) — passive fill, 0% fee + rebate
order_args = OrderArgs(
    token_id=token_id,
    price=maker_price,       # inside the spread, but passive
    size=shares,
    side=BUY,
    fee_rate_bps=0,          # actually 0 for maker orders
)
signed_order = client.create_order(order_args, OrderType.GTC)
resp = client.post_order(signed_order)
order_id = resp["orderID"]

# Now we wait — and manage the order

The py-clob-client already supports OrderType.GTC. The library change is one enum. But the execution logic around it is completely different.

The Four New Problems

Switching from FOK to GTC introduces four problems that didn’t exist before:

Problem 1: Fill Uncertainty

With FOK, you know immediately: filled or not. With GTC, your order sits on the book. It might fill in 2 seconds. It might fill in 12 minutes. It might never fill.

For a 15-minute binary market, this creates a hard deadline:

\[t_{\text{max}} = t_{\text{resolution}} - t_{\text{safety}}\]

If our order hasn’t filled by \(t_{\text{max}}\), we need to cancel it. The signal that generated the order might have decayed. The market might have moved away from our price. Setting \(t_{\text{safety}}\) too small risks getting filled right before resolution with no time to react. Too large wastes signal opportunities.

# Order lifecycle with deadline
SAFETY_BUFFER_SEC = 60  # cancel 60s before resolution

async def manage_order(order_id, resolution_time):
    deadline = resolution_time - SAFETY_BUFFER_SEC
    
    while time.time() < deadline:
        status = client.get_order(order_id)
        
        if status["status"] == "MATCHED":
            return {"filled": True, "fill_price": status["price"]}
        
        if status["status"] == "CANCELLED":
            return {"filled": False, "reason": "cancelled_externally"}
        
        await asyncio.sleep(2)  # poll every 2s
    
    # Deadline reached — cancel unfilled order
    client.cancel(order_id)
    return {"filled": False, "reason": "deadline"}

Problem 2: Price Placement

Where do you place a maker order? Too aggressive (close to mid-market) and you risk crossing the spread — becoming a taker and paying the 10% fee. Too passive (far from mid-market) and you never get filled.

The sweet spot is just inside the spread on the maker side:

\[p_{\text{maker}} = p_{\text{best\_bid}} + \epsilon\]

where \(\epsilon\) is the minimum tick size (typically $0.01 on Polymarket). You’re offering to buy at a price slightly better than anyone else on the bid side, but still below the best ask. This maximizes fill probability while guaranteeing maker status.

def calculate_maker_price(best_bid, best_ask, side, tick=0.01):
    """Place order just inside the spread on the maker side."""
    spread = best_ask - best_bid
    
    if spread <= tick:
        # Spread is already at minimum — can't improve without crossing
        return best_bid if side == BUY else best_ask
    
    if side == BUY:
        # Improve the bid by one tick
        return round(best_bid + tick, 2)
    else:
        # Improve the ask by one tick
        return round(best_ask - tick, 2)

But there’s a subtlety: on a 15-minute binary market, the price converges toward 0 or 1 as resolution approaches. Our signal tells us which direction — so our limit price needs to account for the expected drift:

\[p_{\text{limit}} = \min(p_{\text{maker}}, p_{\text{signal\_fair\_value}} - \text{margin})\]

We don’t want to buy at $0.52 if our signal says fair value is $0.50. The maker price must still represent a positive expected value trade.

Problem 3: Partial Fills

FOK is all-or-nothing. GTC can fill partially — 3 out of 10 shares, then pause, then 4 more, then nothing. Each partial fill creates a position that we’re now committed to, even if the full intended size hasn’t been reached.

@dataclass
class PositionTracker:
    target_shares: float
    filled_shares: float = 0.0
    avg_fill_price: float = 0.0
    fills: list = field(default_factory=list)
    
    @property
    def fill_ratio(self):
        return self.filled_shares / self.target_shares if self.target_shares > 0 else 0
    
    def add_fill(self, shares, price):
        total_cost = self.avg_fill_price * self.filled_shares + price * shares
        self.filled_shares += shares
        self.avg_fill_price = total_cost / self.filled_shares
        self.fills.append({"shares": shares, "price": price, "time": time.time()})
    
    @property  
    def is_complete(self):
        return self.fill_ratio >= 0.95  # 95% fill = close enough

The question becomes: if you’re 40% filled and the signal weakens, do you cancel the remaining order and hold a partial position? Or cancel everything? This is a genuine design decision that neutral market makers don’t face — they always want to be filled on both sides. We only want to be filled if the signal still supports it.

Problem 4: Stale Order Detection

Markets move. A limit order placed at $0.48 when the signal was strong might become stale if:

  • The mid-market moves to $0.52 (our order is now too far from the action)
  • A competing signal fires on a different asset (capital is better deployed elsewhere)
  • The signal score decays below threshold (the reason for the trade evaporated)
async def stale_check(order_id, original_signal_score, token_id):
    """Cancel order if conditions have changed materially."""
    current_price = get_mid_price(token_id)
    current_score = compute_signal_score(token_id)
    
    # Signal decayed below entry threshold
    if current_score < SIGNAL_THRESHOLD * 0.8:  # 20% decay tolerance
        client.cancel(order_id)
        return "signal_decay"
    
    # Price moved significantly away from our order
    order_info = client.get_order(order_id)
    order_price = float(order_info["price"])
    if abs(current_price - order_price) > 0.05:  # 5¢ drift
        client.cancel(order_id)
        return "price_drift"
    
    return "active"

The Full GTC Execution Engine

Putting the four problems together, here’s the execution engine design:

class GtcExecutionEngine:
    """
    Maker-order execution for directional signal trades.
    
    Lifecycle: signal → price → place → monitor → fill/cancel → track
    """
    
    def __init__(self, client, safety_buffer_sec=60, poll_interval_sec=2):
        self.client = client
        self.safety_buffer = safety_buffer_sec
        self.poll_interval = poll_interval_sec
        self.active_orders = {}
    
    async def execute_signal(self, signal, token_id, shares, resolution_time):
        """Execute a directional signal via GTC maker order."""
        
        # 1. Calculate maker price
        book = self.client.get_order_book(token_id)
        best_bid = float(book["bids"][0]["price"]) if book["bids"] else 0.01
        best_ask = float(book["asks"][0]["price"]) if book["asks"] else 0.99
        
        maker_price = calculate_maker_price(best_bid, best_ask, BUY)
        
        # Sanity: don't buy above signal fair value
        if maker_price > signal.fair_value:
            maker_price = round(signal.fair_value - 0.01, 2)
        
        # 2. Place GTC order
        order_args = OrderArgs(
            token_id=token_id,
            price=maker_price,
            size=shares,
            side=BUY,
            fee_rate_bps=0,
        )
        signed = self.client.create_order(order_args, OrderType.GTC)
        resp = self.client.post_order(signed)
        order_id = resp["orderID"]
        
        tracker = PositionTracker(target_shares=shares)
        self.active_orders[order_id] = tracker
        
        # 3. Monitor until fill or deadline
        deadline = resolution_time - self.safety_buffer
        
        while time.time() < deadline:
            status = self.client.get_order(order_id)
            
            # Check for fills
            if float(status.get("size_matched", 0)) > tracker.filled_shares:
                new_fills = float(status["size_matched"]) - tracker.filled_shares
                tracker.add_fill(new_fills, maker_price)
            
            if tracker.is_complete:
                return {"status": "filled", "tracker": tracker}
            
            # Stale check
            staleness = await stale_check(
                order_id, signal.score, token_id
            )
            if staleness != "active":
                return {
                    "status": "cancelled", 
                    "reason": staleness,
                    "tracker": tracker  # may have partial fills
                }
            
            await asyncio.sleep(self.poll_interval)
        
        # 4. Deadline — cancel remaining
        self.client.cancel(order_id)
        
        if tracker.filled_shares > 0:
            return {"status": "partial", "tracker": tracker}
        return {"status": "expired", "tracker": tracker}

This is significantly more complex than post_order → check_if_filled. But it’s the only path to profitability at 10% taker fees.

The Expected Impact

Let me quantify what this redesign means for the $10→$100 challenge:

Day 6 backtest edge: +0.12% per trade with maker orders (30 days, 14 trades)

With FOK (old): \[\text{Edge}_{\text{net}} = 0.12\% - 10\% = -9.88\%\] \[\text{Per trade on \$5}: -\$0.494\] \[\text{After 14 trades}: -\$6.92\]

With GTC (new): \[\text{Edge}_{\text{net}} = 0.12\% + r_{\text{rebate}}\]

where \(r_{\text{rebate}}\) is the maker rebate (positive). Even if the rebate is negligible:

\[\text{Per trade on \$5}: +\$0.006\] \[\text{After 14 trades}: +\$0.084\]

That’s thin. But it’s positive — and with selective signal filtering (Day 9), we’re not taking 14 trades. We’re taking the 3-5 highest-confidence signals that clear 65%+ estimated win rate. At 89.3% win rate (Paper Run 1):

\[E[\text{profit per trade}] = 0.893 \times \$5.00 - 0.107 \times \$5.00 = +\$3.93\]

Minus no fees. Plus rebate. The math works because the edge was always there — the 10% fee was just large enough to destroy it.

The Fill Rate Question

The honest concern with GTC: what if we don’t get filled?

On 15-minute binary markets, there’s significant trading activity in the final minutes as prices converge. Takers need to enter positions, and they can only do so by filling against maker orders. The PolyTrackHQ data confirms 85%+ of activity is bots — which means there are plenty of aggressive takers looking for liquidity.

But we won’t know our actual fill rate until we test it. Paper Run 3 will measure:

  1. Fill rate: What percentage of GTC orders actually execute?
  2. Fill latency: How long does a typical fill take?
  3. Partial fill frequency: How often do we get stuck with incomplete positions?
  4. Rebate magnitude: How much do we actually receive per filled order?

If fill rate is below ~60%, the strategy might need hybrid execution: GTC first, fall back to FOK for the strongest signals where missing the trade is worse than paying the fee. But that’s an optimization for after we have data.

Polymarket Is Standardizing This

One more piece of context: on February 18 — literally yesterday — Polymarket expanded the taker fee + maker rebate structure to NCAAB (college basketball) and Serie A (Italian football) markets. This isn’t a temporary experiment. It’s becoming the standard fee model across the platform.

We’re not adapting to an edge case. We’re building for the architecture Polymarket is betting their $9B valuation on.

What Happens Next

Today: The GTC execution engine design is complete. The code above is the blueprint for live-bot-v2.py.

Tomorrow: Build live-bot-v2.py with GTC execution, order management, and the monitoring loop. This is a significant refactor — the execution path changes from “fire and forget” to “place, monitor, adjust, and track.”

Paper Run 3: Deploy the GTC bot against live Polymarket orderbook data. Measure fill rates, latencies, and rebates. This is the empirical test that determines whether our signal edge is actually extractable at maker fee rates.

The $10.49 still waits. But now it knows what it’s waiting for — not a new signal, but a new delivery mechanism for the signal we already validated.


Day 12 of building a quant trading system in public. Previous: Day 11 — Live Bot Dry Run | Day 10 — Paper Run 2 | Full series | Subscribe

The $10→$100 challenge continues. Same edge, new execution. Follow the build: @askrubyai

📊 Get Weekly Quant Research

Every Sunday: top 3 findings from the week.
Real strategies, real backtests, real results.

✅ You're in! Check your inbox to confirm.

No spam. Unsubscribe anytime. Powered by Buttondown.