This guide walks you through the core mechanics, entry logic, sizing, and execution patterns behind a both-sides spread-capture bot for Polymarket 5-minute BTC Up/Down markets. You will build the brain of the bot, not a plug-and-play system that prints money.
- Why accumulating both Up and Down on their separate dips can lock profit regardless of Bitcoin's direction, even though you can never buy both sides under $1.00 at the same instant
- How to read the CLOB order book and compute the paired cost gate
- How to size positions using inverse-weighted allocation to minimize blended cost
- How to buy each leg on its own dips while keeping your blended cost across the pair under the ceiling
- Why there is no exit management and what happens if you add one
- How to schedule sessions, monitor edge longevity, and respect capacity limits
- Basic Python and async/await programming knowledge
- A Polymarket account with USDC funded on Polygon
- The py-clob-client-v2 SDK installed and authenticated
- Familiarity with binary prediction markets and how they settle
This is not a ready-made profitable bot
This tutorial teaches the fundamental logic and mechanics that underlie a spread-capture or market-making bot. Following these steps does not guarantee a profitable system out of the box. Real profitability depends on execution latency, fee structure, market conditions, capital size, and your own implementation quality. Treat everything here as the conceptual and technical foundation, not a finished product.
Understand the Core Mechanic: Why Accumulating Both Sides Can Be Profitable
Before writing a single line of bot code, you need to understand the one idea the entire strategy rests on. This step explains the math behind paired-cost accumulation, why it produces locked profit regardless of Bitcoin's direction, and what a real session looks like in numbers. If this mechanic does not make sense to you, nothing else in the guide will hold together.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial. I'm on Step 01: Understand the Core Mechanic: Why Accumulating Both Sides Can Be Profitable. Step goal: Before writing a single line of bot code, you need to understand the one idea the entire strategy rests on. This step explains the math behind paired-cost accumulation, why it produces locked profit regardless of Bitcoin's direction, and what a real session looks like in numbers. If this mechanic does not make sense to you, nothing else in the guide will hold together. Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on. --- Specific sub-tasks to complete during this step: ## TASK 1: Ask an AI to explain and verify the paired-cost math Use this prompt before writing any code to make sure you have a solid mental model of the mechanic. Paste it into ChatGPT or Claude. I am building a spread-capture bot for Polymarket 5-minute BTC Up/Down binary markets. The core mechanic is temporal accumulation, NOT a simultaneous arbitrage. At any instant the best ask on Up plus the best ask on Down is always greater than $1.00, so I can never buy both sides under $1.00 at the same moment. Instead, because the two sides seesaw, I buy each side only on its own dips, at different times in the 5-minute window. My goal is for the blended average cost across the two sides (avg_up + avg_down) to end up below $1.00. At settlement exactly one side pays $1.00 and the other pays $0.00, so each completed pair returns $1.00. Please do the following: 1. Confirm the math with a worked example where I accumulate Up at a blended $0.36 and Down at a blended $0.53, bought at different moments. 2. Explain why, at any single instant, Ask_Up + Ask_Down is always above $1.00, and why that does not prevent the blended pair cost from ending below $1.00. 3. Explain in plain terms why this is not a directional bet. 4. Explain why a 37% win rate on individual positions is structurally expected and not a problem. 5. Identify the edge cases where this breaks down (fees, a market that never seesaws back so one side stays expensive, ending the window holding an unbalanced pair).
The core idea: one side always pays $1.00
A Polymarket 5-minute BTC Up/Down market is a binary contract. At settlement, exactly one outcome is true. The Up token pays $1.00 and the Down token pays $0.00, or vice versa. There is no middle ground. The rational combined price of Up plus Down is always $1.00, because together they represent certainty.
Here is the catch that defines the whole strategy: at any single instant, the best ask on Up and the best ask on Down always sum to MORE than $1.00. If they ever summed to less than $1.00, that would be free money, and bots would take it within milliseconds. You can never buy both sides under $1.00 at the same moment. The edge does not live in one instant. It lives across time. Because Up and Down seesaw - when one dips, the other peaks - their cheap moments never line up. You buy Up only when Up dips and Down only when Down dips, at different points in the window. Each side's blended average reflects only its own cheap moments, and two separately bargain-hunted averages can add up to well below $1.00.
This is not a directional bet. You do not need to know or predict whether Bitcoin goes up or down. The profit is structural, baked in once you hold a balanced pair whose blended cost across both sides is below $1.00. The only variable that matters is whether your accumulated average across the two sides stays under your ceiling.
The arithmetic with real numbers
Across one 5-minute window you accumulate Up only on its dips, filling at a blended $0.36, and Down only on its dips, filling at a blended $0.53. Crucially, at no single moment were both of those prices on offer together: when Up was at $0.36, Down was trading up near $0.64; when Down dipped to $0.53, Up had bounced back to about $0.47. Your blended pair cost is $0.36 + $0.53 = $0.89. At settlement one side pays $1.00 and the other $0.00, so each completed pair returns $1.00 against $0.89 paid: $0.11 of profit per pair, locked once the pair is complete.
A reference wallet running this strategy hit a median paired cost of $0.809 across all paired markets in a single session. That is roughly $0.19 of guaranteed margin per paired dollar before the outcome is known.
A 37% win rate is healthy here
Because you always buy both sides, the losing leg resolves to $0.00 every trade. Your win rate on individual positions will look terrible, around 37% to 50%. That number is meaningless for this strategy. The only score that matters is total settlement received minus total capital deployed. Do not optimize for win rate. It will destroy the edge.
How to read your bottom line
Ignore gross spread figures and per-trade win/loss breakdowns. The only number that tells you whether the strategy is working is this: total settlement received minus total USDC deployed. If that number is positive, the bot is capturing spread. If it is negative, your blended cost across the pair ended too high - usually because you chased a side up to complete a pair instead of waiting for its dip, or fees ate the margin.
The formula is simple. Net P/L equals total winning-side shares times $1.00, minus total USDC deployed. Run this calculation at the end of every session, not trade by trade.
def compute_session_pnl(settled_positions: list[dict]) -> dict:
"""
settled_positions: list of dicts with keys:
- 'side': 'up' or 'down'
- 'shares': float
- 'cost_usdc': float (what you paid)
- 'resolved_winner': bool (True if this side paid $1.00)
"""
total_deployed = sum(p['cost_usdc'] for p in settled_positions)
total_settled = sum(
p['shares'] * 1.00
for p in settled_positions
if p['resolved_winner']
)
net_pnl = total_settled - total_deployed
return_pct = (net_pnl / total_deployed) * 100 if total_deployed > 0 else 0.0
return {
"total_deployed": round(total_deployed, 4),
"total_settled": round(total_settled, 4),
"net_pnl": round(net_pnl, 4),
"return_pct": round(return_pct, 4),
}
You understand the mechanic when you can answer this
Ask yourself: you already hold Down at an average of $0.55, and Up is now offered at $0.50. Should you complete the pair by buying Up? Your blended pair cost would be $0.55 + $0.50 = $1.05, a guaranteed $0.05 loss per pair, so the answer is no. Never buy a side at a price that pushes your blended pair cost to or above $1.00. The hard reject on that condition - buy a side only when its ask is at or below your ceiling minus your average on the other side - is the most important rule in the entire bot. Forcing the second leg at a bad price is the single biggest way these bots blow up.
Define Your Universe: What to Trade and What to Ignore
The strategy only works in specific market conditions. Picking the wrong markets is the fastest way to destroy the edge before the bot even runs. This step defines the exact universe of markets the bot should trade, explains why each filter exists, and shows you how to implement the whitelist in code. Narrow is correct here. Broad is expensive.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial.
I'm on Step 02: Define Your Universe: What to Trade and What to Ignore.
Step goal: The strategy only works in specific market conditions. Picking the wrong markets is the fastest way to destroy the edge before the bot even runs. This step defines the exact universe of markets the bot should trade, explains why each filter exists, and shows you how to implement the whitelist in code. Narrow is correct here. Broad is expensive.
Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on.
---
Specific sub-tasks to complete during this step:
## TASK 1: Generate a market scanner that fetches and filters the live universe
Use this after you have py-clob-client-v2 installed and authenticated. It will build the live market fetch and filter pipeline.
I am building a Polymarket spread-capture bot targeting only BTC and ETH 5-minute Up/Down markets.
Using the py-clob-client-v2 SDK, write an async Python function called `fetch_tradeable_markets` that:
1. Calls the Polymarket CLOB API to fetch all currently active markets.
2. Filters to only markets whose slug matches `btc-updown-5m-*` or `eth-updown-5m-*`.
3. Filters out any market that closes within the next 30 seconds (use the market's end_date_iso field).
4. Returns a list of dicts, each containing: slug, asset ('BTC' or 'ETH'), close_time (datetime), condition_id, and token_ids for both Up and Down outcomes.
5. Logs how many markets were fetched and how many passed the filter.
Assumptions:
- The CLOB client is already instantiated as `client`.
- Use Python's `asyncio` and `aiohttp` where appropriate.
- Handle the case where the API returns an empty list gracefully.
- Add a docstring explaining what the function does and what it returns.
Why the universe is deliberately narrow
The spread-capture edge lives in thin, fast-moving order books where the two sides swing hard and often, because market makers cannot actively manage risk inside a short resolution window. The thinner the book and the shorter the duration, the deeper and more frequent each side's dips, and the lower the blended pair cost you can accumulate across the window. That gap between your blended cost and $1.00 is your margin. At any single instant the two best asks still sum to more than $1.00; the margin comes from buying each side on its separate dips, never from a momentary combined price below $1.00.
5-minute BTC and ETH Up/Down markets are the target. A fresh window opens every five minutes per asset, giving you up to 288 markets per day per asset. That frequency is the whole point: many small, independent spread locks compound into a real daily return. Longer durations attract more competition and oscillate less. On a 15-minute or hourly window the two sides barely seesaw, so the best blended pair cost you can accumulate typically lands between $0.95 and $0.98, leaving almost nothing after fees.
Everything outside BTC and ETH 5-minute windows is excluded. No SOL, no sports, no politics, no 15-minute or hourly markets. Each of those categories has different microstructure, different fee regimes, and different liquidity profiles. Mixing them into the same bot without separate validation is how you introduce losses you cannot diagnose.
Market slug patterns
Polymarket market slugs for the target universe follow predictable patterns: btc-updown-5m-* and eth-updown-5m-*. Your market scanner should filter on these prefixes and reject everything else at the slug level before any order book work happens.
Asset split in the reference wallet was roughly 80% BTC and 20% ETH by capital. ETH 5-minute books tend to have slightly wider spreads but less depth. A capacity-aware bot can prioritize ETH when paired cost is attractive, then load BTC for volume.
Longer durations kill the margin
On 15-minute or hourly markets the two sides barely oscillate, so the best blended pair cost you can accumulate typically lands at $0.95 to $0.98. After a 2-3% taker fee on crypto, that leaves zero or negative margin. The edge is structural to thin, fast-swinging 5-minute books. Do not extend the duration filter without re-validating the fee math from scratch.
import re
from dataclasses import dataclass
@dataclass
class Market:
slug: str
asset: str
duration: str
is_active: bool
ALLOWED_ASSETS = {"BTC", "ETH"}
ALLOWED_DURATION = "5m"
SLUG_PATTERNS = [
re.compile(r"^btc-updown-5m-"),
re.compile(r"^eth-updown-5m-"),
]
def is_tradeable_market(market: Market) -> bool:
"""Return True only if this market is in the allowed universe."""
if not market.is_active:
return False
if market.asset not in ALLOWED_ASSETS:
return False
if market.duration != ALLOWED_DURATION:
return False
if not any(p.match(market.slug) for p in SLUG_PATTERNS):
return False
return True
# Example usage
markets = [
Market(slug="btc-updown-5m-1700000000", asset="BTC", duration="5m", is_active=True),
Market(slug="eth-updown-5m-1700000300", asset="ETH", duration="5m", is_active=True),
Market(slug="btc-updown-15m-1700000000", asset="BTC", duration="15m", is_active=True),
Market(slug="sol-updown-5m-1700000000", asset="SOL", duration="5m", is_active=True),
]
tradeable = [m for m in markets if is_tradeable_market(m)]
print([m.slug for m in tradeable])
# ['btc-updown-5m-1700000000', 'eth-updown-5m-1700000300']
Verify your universe filter works
Run your market scanner and print the slugs of every market it returns. Every slug should start with btc-updown-5m- or eth-updown-5m-. If you see any other pattern, your filter has a bug. Fix it before moving to the entry logic step. A single non-5-minute market slipping through can silently drain capital on low-margin entries.
Build the Entry Logic: Reading the Order Book and Checking the Gate
The entry gate is the most important function in the bot. It reads the CLOB order book and your current position, and decides whether buying a given side right now keeps your blended pair cost under the ceiling. There is no single-instant 'combined ask below $1.00' check, because that never happens. Every other piece of the bot depends on this gate being correct. This step walks through each check, explains why each exists, and shows the complete reference implementation.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial.
I'm on Step 03: Build the Entry Logic: Reading the Order Book and Checking the Gate.
Step goal: The entry gate is the most important function in the bot. It reads the CLOB order book and your current position, and decides whether buying a given side right now keeps your blended pair cost under the ceiling. There is no single-instant 'combined ask below $1.00' check, because that never happens. Every other piece of the bot depends on this gate being correct. This step walks through each check, explains why each exists, and shows the complete reference implementation.
Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on.
---
Specific sub-tasks to complete during this step:
## TASK 1: Generate a CLOB WebSocket snapshot reader for the gate function
Use this after implementing the gate function above. It builds the live order book feed that the gate reads from.
I have a Polymarket spread-capture bot with a gate function that reads `best_ask_up` and `best_ask_down` from a CLOB snapshot dict.
Using the py-clob-client-v2 SDK and Python asyncio, write an async class called `CLOBBookFeed` that:
1. Connects to the Polymarket CLOB WebSocket and subscribes to order book updates for a list of market condition IDs.
2. Maintains an in-memory dict keyed by condition_id, storing the current best ask for the Up token and the Down token.
3. Exposes a method `get_snapshot(condition_id) -> dict` that returns {best_ask_up, best_ask_down, depth_up, depth_down} or None if no data is available yet.
4. Handles reconnection automatically if the WebSocket drops.
5. Sends a heartbeat ping every 5 seconds to keep the connection alive (Polymarket cancels orders if no heartbeat within ~10 seconds).
6. Logs a warning if a snapshot has not been updated in more than 15 seconds.
Assumptions:
- Each market has two token IDs: one for Up and one for Down.
- The bot passes a list of {condition_id, token_id_up, token_id_down} dicts on initialization.
- Use Python 3.11+ syntax with asyncio and websockets library.
What the gate checks and why
The gate function takes a market, the side you would buy, that side's current best ask, and your current position, and returns either a buy instruction or None. It is a pure function with no side effects. That matters because you want to test it in isolation, run it in simulation, and run it live with the exact same code.
The checks happen in this order: asset and duration whitelist, UTC hour sleep window, order book completeness, time-to-close guard, the per-side blended-cost check, and the imbalance guard. Each check is a hard reject. If any fails, the function returns None and the bot waits for the next dip. There is no forcing a leg, no retry, no override.
The blended-cost check is the core condition. You never add the two asks together at one instant - that sum is always above $1.00. Instead you check a single side at a time: buy Up only if its ask is at or below C minus your current average on Down, and buy Down only if its ask is at or below C minus your current average on Up. That keeps avg_up + avg_down at or under the ceiling C as you accumulate. The reference implementation uses C = $0.95 conservative and $0.90 aggressive; the reference wallet accumulated a median blended pair cost of $0.809, so $0.90 is a reasonable target. Subtract your fee per pair from C before comparing. On Polymarket crypto taker mode, fees run roughly 2 to 4 cents per pair, so a raw C of $0.99 becomes about $0.95 effective. Always use the fee-adjusted ceiling.
from datetime import datetime, timezone
from typing import Optional
# Fee per pair in USDC (crypto taker mode on Polymarket)
# Adjust this to match your actual fee tier
FEE_PER_PAIR = 0.03
# Raw ceiling on the blended pair cost (avg_up + avg_down) before fee adjustment
RAW_CEILING_CONSERVATIVE = 0.95
RAW_CEILING_AGGRESSIVE = 0.90
# Fee-adjusted ceilings (what the gate actually uses)
C_EFFECTIVE_CONSERVATIVE = RAW_CEILING_CONSERVATIVE - FEE_PER_PAIR # 0.92
C_EFFECTIVE_AGGRESSIVE = RAW_CEILING_AGGRESSIVE - FEE_PER_PAIR # 0.87
# Price ceiling for the FIRST leg on a side, before you hold any of the other
# side. You have no average to subtract yet, so cap the opening buy low enough
# that the pair can still be completed cheaply when the other side dips.
OPENING_LEG_MAX = 0.55
# Largest share imbalance allowed between the two sides while accumulating.
MAX_IMBALANCE_SHARES = 40.0
# UTC hours to skip (dead hours with thin books and little oscillation)
SLEEP_HOURS_UTC = {22, 23, 0, 1, 2, 3, 8}
# Minimum seconds remaining before close to allow a buy
MIN_SECONDS_TO_CLOSE = 10
def should_buy_side(
market: dict,
side: str, # 'up' or 'down' - the side you would buy right now
ask: float, # current best ask for THAT side
position: dict, # shares_up, shares_down, avg_up, avg_down
ceiling: float = C_EFFECTIVE_CONSERVATIVE,
) -> Optional[dict]:
"""
Pure per-side gate. Decides whether buying `side` at `ask` right now keeps the
blended pair cost (avg_up + avg_down) at or below `ceiling`.
There is no "combined ask" check: at any instant ask_up + ask_down is always
above $1.00, so the two asks are never added together and compared. The edge is
earned by buying each side only on its own dips, at different moments, so each
side's blended average lands low.
Returns a buy instruction dict if the buy is allowed, or None.
"""
# 1. Asset and duration whitelist
if market["asset"] not in ("BTC", "ETH"):
return None
if market["duration"] != "5m":
return None
# 2. Sleep window: skip dead hours
if datetime.now(timezone.utc).hour in SLEEP_HOURS_UTC:
return None
# 3. Order book completeness
if ask is None:
return None
# 4. Time-to-close guard
now = datetime.now(timezone.utc)
if (market["close_time"] - now).total_seconds() < MIN_SECONDS_TO_CLOSE:
return None
shares_up = position.get("shares_up", 0.0)
shares_down = position.get("shares_down", 0.0)
avg_up = position.get("avg_up", 0.0)
avg_down = position.get("avg_down", 0.0)
if side == "up":
this_shares, other_shares, other_avg = shares_up, shares_down, avg_down
else:
this_shares, other_shares, other_avg = shares_down, shares_up, avg_up
# 5. Core condition: keep the blended pair cost at or below the ceiling.
if other_shares <= 0:
# No other side held yet: this is an opening leg. There is no average to
# subtract, so cap it so the pair can still complete cheaply on the flip.
max_price = OPENING_LEG_MAX
else:
# Buy this side only if it keeps avg(this) + avg(other) <= ceiling.
max_price = ceiling - other_avg
if ask > max_price:
return None
# 6. Imbalance guard: do not pile further onto an already-heavy side.
if this_shares - other_shares >= MAX_IMBALANCE_SHARES:
return None
projected = ask if other_shares <= 0 else round(other_avg + ask, 4)
return {
"side": side,
"ask": ask,
"max_price": round(max_price, 4),
"projected_pair_cost": projected, # blended cost if this fills here
}
Always use the fee-adjusted threshold, never the raw one
A raw threshold of $0.99 looks like it leaves $0.01 of margin. After a 3-cent taker fee per pair, your actual margin is negative $0.02. Every entry at that price is a guaranteed loss. Compute C_effective as your raw threshold minus fee per pair and use that number in the gate. If you are unsure of your fee tier, check the Polymarket fee schedule and err on the side of a tighter threshold.
Gate is working when this passes
Feed the gate a position holding Down at avg $0.55 and zero Up, then offer Up at $0.50 with the conservative ceiling C_effective = 0.92. It should return None, because 0.50 > 0.92 - 0.55 = 0.37: buying Up here would push your blended pair cost to $1.05. Now offer Up at $0.35 instead. It should return a buy instruction, because 0.35 <= 0.37 and your blended pair cost would be $0.90. If both cases behave correctly, the per-side gate is sound.
Size Your Positions: Inverse-Weighted Allocation and Clip Limits
Once the gate says a side is buyable, you need to decide how much to buy. The naive answer is to split your capital evenly across the two sides. The correct answer is to end the window holding more of whichever side dipped cheaper. This is not a directional bet. It is a mathematical technique for lowering your blended pair cost, which widens your locked margin. This step explains the inverse-weighting target, shows its effect on real numbers, and sets the clip size limits that keep you inside the depth of thin books.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial. I'm on Step 04: Size Your Positions: Inverse-Weighted Allocation and Clip Limits. Step goal: Once the gate says a side is buyable, you need to decide how much to buy. The naive answer is to split your capital evenly across the two sides. The correct answer is to end the window holding more of whichever side dipped cheaper. This is not a directional bet. It is a mathematical technique for lowering your blended pair cost, which widens your locked margin. This step explains the inverse-weighting target, shows its effect on real numbers, and sets the clip size limits that keep you inside the depth of thin books. Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on. --- Specific sub-tasks to complete during this step: ## TASK 1: Generate unit tests for the allocation and clip guard functions Use this after implementing compute_allocations and apply_clip_guards. Paste into Claude Code or Cursor to generate a pytest test suite. I have two Python functions in sizing.py for a Polymarket spread-capture bot: 1. `compute_allocations(ask_up, ask_down, base_clip_usdc, min_side_usdc=0.50)` — returns (usdc_up, usdc_down) using inverse price weighting. 2. `apply_clip_guards(usdc_up, usdc_down, already_deployed_in_market)` — enforces MAX_CLIP_PER_SIDE_USDC=6.00 and MAX_CAPITAL_PER_MARKET=20.00, returns None if market cap is hit. Write a pytest test suite (test_sizing.py) that covers: 1. Symmetric book (ask_up=0.50, ask_down=0.50): both sides should get equal allocation. 2. Asymmetric book (ask_up=0.20, ask_down=0.75): Up side should get significantly more capital. 3. Extreme asymmetry (ask_up=0.08, ask_down=0.87): verify blended cost is well below the simple average. 4. Min floor: even with extreme skew, neither side gets less than 0.50 USDC. 5. Clip guard: clips above MAX_CLIP_PER_SIDE_USDC are capped correctly. 6. Market cap: when already_deployed_in_market >= 20.00, returns None. 7. Proportional scale-down: when combined allocation exceeds remaining budget, both sides scale proportionally. Use pytest and assert exact values where possible. Add a brief docstring to each test explaining what it is checking.
Why you allocate more to the cheaper side
When one side is significantly cheaper than the other, buying more of the cheap side lowers the blended cost of the pair. The cheap side is the one that is unlikely to win at settlement. That is why it is cheap. But you are not betting on it winning. You are using it to reduce your average entry cost across the pair.
The inverse-weighting formula assigns capital proportional to the complement of each side's price. A side priced at $0.20 gets a weight proportional to $0.80. A side priced at $0.75 gets a weight proportional to $0.25. The result is that more capital flows to the cheaper side, pulling the blended cost down.
The effect compounds as the skew gets more extreme. When both sides end up accumulated near $0.49, equal share counts and inverse weighting produce the same result. When one side has dipped to a blended $0.08 while the other sits at a blended $0.87, weighting your accumulated shares toward the cheap side can cut the blended pair cost from $0.95 (equal share counts) to $0.70, adding 25 cents of locked margin per paired share. That is the entire story behind the dominance ratios in the reference data.
def compute_allocations(
ask_up: float,
ask_down: float,
base_clip_usdc: float,
min_side_usdc: float = 0.50,
) -> tuple[float, float]:
"""
Compute TARGET USDC allocations for the Up and Down legs using inverse price
weighting. You fill toward these targets as each side dips on its own - you do
NOT buy both legs at once. More capital is aimed at the cheaper side to lower
the blended pair cost you accumulate across the window.
Args:
ask_up: Best ask price for the Up token (e.g. 0.32)
ask_down: Best ask price for the Down token (e.g. 0.57)
base_clip_usdc: Base clip size per side in USDC (e.g. 3.00)
min_side_usdc: Minimum allocation per side (always touch both)
Returns:
(usdc_up, usdc_down) tuple
"""
total_budget = base_clip_usdc * 2
# Inverse price weights: cheaper side gets higher weight
w_up = (1.0 - ask_up) / ((1.0 - ask_up) + (1.0 - ask_down))
w_down = (1.0 - ask_down) / ((1.0 - ask_up) + (1.0 - ask_down))
usdc_up = round(total_budget * w_up, 2)
usdc_down = round(total_budget * w_down, 2)
# Floor: always touch both sides
usdc_up = max(usdc_up, min_side_usdc)
usdc_down = max(usdc_down, min_side_usdc)
return usdc_up, usdc_down
# Worked examples
for ask_up, ask_down in [(0.50, 0.48), (0.35, 0.62), (0.20, 0.75), (0.08, 0.87)]:
u, d = compute_allocations(ask_up, ask_down, base_clip_usdc=3.00)
blended = (u * ask_up + d * ask_down) / (u + d)
print(f"Up={ask_up:.2f} Down={ask_down:.2f} | alloc_up=${u:.2f} alloc_down=${d:.2f} | blended={blended:.3f}")
Clip size limits and why they are hard constraints
The reference wallet used clips between $1.50 and $6.00 per side. The P95 fill was $4.48 and the max was $6.32. These are not style choices. They are hard limits imposed by the depth of thin 5-minute books.
Clips above $6 to $8 per side start moving the ask against you. You are lifting your own paired cost by consuming the available depth. Once you move the ask, your entry price is worse than the price that triggered the gate.
Bankroll-to-clip sizing reference
At $2,000 bankroll: base clip $1.50, max clip $6.00, ~$2,000 daily deployment, ~$76 expected daily P/L. At $10,000: base clip $1.50-$3.00, max clip $6.00, ~$9,700 deployed, ~$370 P/L. At $25,000: base clip $2.50-$5.00, max clip $10.00, ~$15,000 deployed. Above $50,000, fragment across wallets.
MAX_CLIP_PER_SIDE_USDC = 6.00 # Hard cap per individual fill
MAX_CAPITAL_PER_MARKET = 20.00 # Hard cap total USDC deployed in one market window
def apply_clip_guards(
usdc_up: float,
usdc_down: float,
already_deployed_in_market: float,
) -> tuple[float, float] | None:
"""
Apply hard clip and per-market capital limits.
Returns adjusted (usdc_up, usdc_down) or None if the market is full.
Args:
usdc_up: Proposed Up allocation from compute_allocations
usdc_down: Proposed Down allocation from compute_allocations
already_deployed_in_market: USDC already spent in this market window
Returns:
(usdc_up, usdc_down) clipped to limits, or None if market cap is hit
"""
# Hard per-market cap
remaining_budget = MAX_CAPITAL_PER_MARKET - already_deployed_in_market
if remaining_budget <= 0:
return None
# Clip each side
usdc_up = min(usdc_up, MAX_CLIP_PER_SIDE_USDC)
usdc_down = min(usdc_down, MAX_CLIP_PER_SIDE_USDC)
# Scale down proportionally if combined exceeds remaining budget
combined = usdc_up + usdc_down
if combined > remaining_budget:
scale = remaining_budget / combined
usdc_up = round(usdc_up * scale, 2)
usdc_down = round(usdc_down * scale, 2)
return usdc_up, usdc_down
The skew is not a directional signal. Do not treat it as one.
In the reference data, the dominant-side win rate across all dominance buckets sits between 47% and 52%. That is a coin flip. The cheap side does not win more often just because you bought more of it. The only thing that changes with higher skew is the blended paired cost, which drops. If you add a filter that removes high-skew entries because they look like bad directional bets, you will cut your highest-margin trades and reduce overall profitability.
Execute One Leg at a Time, on the Dip: Execution and Operational Requirements
The entry gate and sizing logic are only useful if your orders actually fill near the prices that triggered them. The key thing to understand: you do NOT fire both legs at once. There is no fleeting moment when both sides are cheap together, so there is nothing to race. You buy one side at a time, whenever that side dips and the per-side gate confirms its price keeps your blended pair cost under the ceiling. This step covers the single-leg marketable-buy pattern, the CLOB V2 requirements, and how to track your running pair cost and imbalance across the window.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial. I'm on Step 05: Execute One Leg at a Time, on the Dip: Execution and Operational Requirements. Step goal: The entry gate and sizing logic are only useful if your orders actually fill near the prices that triggered them. The key thing to understand: you do NOT fire both legs at once. There is no fleeting moment when both sides are cheap together, so there is nothing to race. You buy one side at a time, whenever that side dips and the per-side gate confirms its price keeps your blended pair cost under the ceiling. This step covers the single-leg marketable-buy pattern, the CLOB V2 requirements, and how to track your running pair cost and imbalance across the window. Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on. --- Specific sub-tasks to complete during this step: ## TASK 1: Generate a running position monitor that tracks blended cost and imbalance Use this after implementing buy_one_leg. It tracks the accumulated pair per market and flags windows that close holding one side heavier than the other. I have a Polymarket spread-capture bot that accumulates each side of a market separately, one leg at a time on its own dips. After each fill I update a running position per market with shares_up, shares_down, cost_up, cost_down, avg_up, avg_down. Write an async class called `PositionMonitor` that: 1. Tracks the running position per market slug as legs are accumulated. 2. Exposes `record_fill(market_slug, side, shares, cost_usdc)` that updates shares, cost, and the recomputed average for that side. 3. Exposes `blended_pair_cost(market_slug)` returning avg_up + avg_down (the number that must stay below $1.00), or None if only one side is held so far. 4. Exposes `imbalance_shares(market_slug)` returning shares_up - shares_down. 5. Exposes `surplus_at_close(market_slug, close_time)` returning the capped one-sided surplus if the window closed while one side is heavier than the other. 6. On a window closing unbalanced, logs a WARNING with the slug, the heavy side, the surplus shares, and the USDC of one-sided exposure. 7. Exposes `total_unpaired_exposure_usdc()` across all markets. 8. Does NOT auto-close anything - it only monitors and logs. The operator decides. Use Python asyncio and type hints. Add a brief comment on why auto-closure is not implemented (forcing the second leg at a bad price is the main way these bots blow up).
Why you buy one leg at a time, not both at once
A common misconception is that you must submit both legs in the same instant to 'lock' a combined price below $1.00. There is no such instant - at every moment the two best asks sum to more than $1.00. So there is nothing to fire simultaneously. Instead, each buy is a standalone decision: a single side has just dipped, the per-side gate confirms its ask is at or below C minus your average on the other side, and you buy that one leg. The other leg was bought earlier on its own dip, or will be bought later on the next one.
Use a plain marketable limit buy for the single side that triggered. A marketable limit (a buy priced a hair above the best ask) fills now but caps the price you will accept, so a fast-moving thin book cannot fill you above the gate price. You are not racing a disappearing arbitrage; you are taking one cheap side while it is cheap, then waiting for the seesaw to hand you the other side cheaply.
Because you accumulate one side at a time, you will naturally be imbalanced in the middle of a window - that is expected, not a bug. The imbalance cap from the gate bounds how lopsided you may get. If a window closes while you still hold more of one side than the other, that surplus is capped, cheap, one-sided inventory: a small directional remainder, not a failed trade. Flag it, log it, and let your risk limits - not panic - decide what to do.
import time
from dataclasses import dataclass
from typing import Optional
@dataclass
class OrderResult:
order_id: str
side: str # 'up' or 'down'
price: float
size_usdc: float
filled: bool
filled_price: Optional[float] = None
async def submit_order(client, token_id: str, side: str, price: float, size_usdc: float) -> OrderResult:
"""Submit a single marketable limit order to the Polymarket CLOB V2."""
# Marketable limit: price a hair above best ask so it fills now, but capped so a
# fast book cannot fill you above the price the gate approved.
limit_price = round(price + 0.001, 4)
shares = round(size_usdc / limit_price, 2)
response = await client.post_order(
token_id = token_id,
price = limit_price,
size = shares,
side = "BUY",
order_type = "LIMIT",
)
return OrderResult(
order_id = response["orderID"],
side = side,
price = limit_price,
size_usdc = size_usdc,
filled = response.get("status") == "MATCHED",
filled_price = response.get("avgPrice"),
)
async def buy_one_leg(
client,
market: dict,
side: str, # the single side that just dipped and passed the gate
ask: float,
size_usdc: float,
position: dict, # running shares_up/down, cost_up/down, avg_up/down
) -> dict:
"""
Buy ONE leg - the side that just dipped. No second leg is fired here; the other
side is accumulated separately, on its own dip. After the fill we fold it into
the running position so the next gate check sees fresh averages, and we report
the blended pair cost (avg_up + avg_down), the number that must stay below $1.00.
"""
t_start = time.monotonic()
token_id = market["token_id_up"] if side == "up" else market["token_id_down"]
result = await submit_order(client, token_id, side, ask, size_usdc)
latency_ms = (time.monotonic() - t_start) * 1000
if result.filled:
shares = round(result.size_usdc / result.filled_price, 4)
s_key, c_key, a_key = f"shares_{side}", f"cost_{side}", f"avg_{side}"
new_shares = position.get(s_key, 0.0) + shares
new_cost = position.get(c_key, 0.0) + result.size_usdc
position[s_key] = round(new_shares, 4)
position[c_key] = round(new_cost, 4)
position[a_key] = round(new_cost / new_shares, 4) if new_shares else 0.0
blended_pair_cost = None
if position.get("shares_up", 0) > 0 and position.get("shares_down", 0) > 0:
blended_pair_cost = round(position["avg_up"] + position["avg_down"], 4)
return {
"market_slug": market["slug"],
"result": result,
"filled": result.filled,
"blended_pair_cost": blended_pair_cost, # avg_up + avg_down; must stay < $1.00
"imbalance_shares": round(position.get("shares_up", 0.0) - position.get("shares_down", 0.0), 4),
"latency_ms": round(latency_ms, 1),
}
CLOB V2 requires a balance-allowance sync on every boot
After the Polymarket CLOB V2 upgrade, you must call /balance-allowance/update before submitting any orders in a new session. Skipping this step is the most common cause of silent order rejections where the API accepts the request but the order never appears in the book. Add this call to your bot's startup sequence and verify it returns a 200 before entering any markets.
async def startup(client, book_feed: CLOBBookFeed, market_scanner) -> list[dict]:
"""
Run all required startup checks before the bot begins trading.
Raises RuntimeError if any critical step fails.
"""
print("[boot] Syncing balance-allowance (CLOB V2 requirement)...")
sync_response = await client.update_balance_allowance()
if sync_response.get("status") != "ok":
raise RuntimeError(f"Balance-allowance sync failed: {sync_response}")
print("[boot] Balance-allowance sync OK")
print("[boot] Connecting to CLOB WebSocket...")
await book_feed.connect()
print("[boot] WebSocket connected")
print("[boot] Fetching tradeable markets...")
markets = await market_scanner.fetch_tradeable_markets()
if not markets:
raise RuntimeError("No tradeable markets found. Check filters and API connectivity.")
print(f"[boot] Found {len(markets)} tradeable markets")
print("[boot] Subscribing to order book feeds...")
await book_feed.subscribe([m["condition_id"] for m in markets])
print("[boot] Subscriptions active. Bot ready.")
return markets
Verify execution is working correctly
After your first few live fills, check three things: each leg shows as filled in the CLOB, your running blended pair cost (avg_up + avg_down) is below your ceiling with room left to complete, and the latency_ms on each buy is under 500. If a fill came in above the gate price, the book moved before you filled - tighten your marketable-limit buffer or improve your WebSocket feed latency. Remember you are not trying to fill both sides at the same instant; you are filling one cheap side at a time and watching the blended average, never a momentary combined price.
Hold to Settlement: Why There Is No Exit Management
Once you hold a balanced pair - both sides accumulated at a blended cost below $1.00 - the bot's job is done for that market window. There are no stop-losses, no take-profits, no mid-window sells. This is not laziness or an oversight. It is the correct behavior given the structure of the trade. This step explains why any exit management destroys the locked profit, what the manage_position function looks like, and what the P/L accounting looks like at settlement.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial. I'm on Step 06: Hold to Settlement: Why There Is No Exit Management. Step goal: Once you hold a balanced pair - both sides accumulated at a blended cost below $1.00 - the bot's job is done for that market window. There are no stop-losses, no take-profits, no mid-window sells. This is not laziness or an oversight. It is the correct behavior given the structure of the trade. This step explains why any exit management destroys the locked profit, what the manage_position function looks like, and what the P/L accounting looks like at settlement. Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on. --- Specific sub-tasks to complete during this step: ## TASK 1: Generate a settlement listener that records final P/L per market Use this after implementing compute_settlement_pnl. It builds the settlement event handler that closes out positions and records results. I have a Polymarket spread-capture bot that holds all positions to settlement. I need a settlement listener that: 1. Subscribes to Polymarket resolution events via the CLOB WebSocket or REST polling (use whichever is more reliable in py-clob-client-v2). 2. When a market resolves, identifies which side (Up or Down) paid $1.00. 3. Looks up all open positions for that market in an in-memory position store. 4. Calls compute_settlement_pnl(position, winner_side) for each position. 5. Logs the result: market slug, total cost, settlement received, net P/L, return percentage. 6. Removes the settled positions from the position store. 7. Accumulates session-level totals: total deployed, total settled, net session P/L, session return percentage. 8. Exposes a method get_session_summary() that returns the session totals. Assumptions: - The position store is a dict keyed by market slug, each value is a list of position dicts. - Use Python asyncio. Handle the case where a market resolves but no positions are held (log and skip). - Add error handling for cases where the resolution event arrives before the position is recorded (retry after 1 second, up to 3 times).
Why selling either leg breaks the trade
When you hold both sides at a blended cost below $1.00, the profit is locked. It does not matter what happens to the prices of the Up and Down tokens between now and settlement. One of them will pay $1.00. The other will pay $0.00. Your net is settlement received minus cost paid.
If you sell the Up leg mid-window because it has risen in price and looks like a winner, you have done two things. First, you have realized a gain on the Up leg. Second, you are now holding only the Down leg, which is likely to pay $0.00 at settlement. You have converted a guaranteed paired profit into a directional bet on the losing side. The locked margin is gone.
The same logic applies to stop-losses. If the Down token drops in price and you sell it to cut losses, you are now holding only the Up leg. That leg may pay $1.00, but you have already paid for both legs. Your net is worse than if you had held both to settlement. There is no scenario where selling one leg improves your outcome relative to holding both.
def manage_position(position: dict) -> None:
"""
Position management for a paired spread-capture trade.
This function intentionally does nothing.
Rationale:
- Profit is locked at entry when both legs fill at combined cost < $1.00.
- Selling the winning leg mid-window removes the hedge and creates naked
directional exposure on the losing leg.
- Selling the losing leg mid-window converts a guaranteed paired profit
into a realized loss on the sold leg plus an unhedged position.
- Stop-losses and take-profits both destroy the locked margin.
- Every position settles at $1.00 (winner) or $0.00 (loser).
- The only action required is to wait for the 5-minute window to close.
The job is done at entry. Do not touch it.
"""
pass
def compute_settlement_pnl(position: dict, winner_side: str) -> dict:
"""
Compute final P/L after settlement.
Args:
position: dict with keys: usdc_up, usdc_down, filled_price_up, filled_price_down,
shares_up, shares_down
winner_side: 'up' or 'down' — the side that resolved to $1.00
Returns:
dict with total_cost, settlement_received, net_pnl, return_pct
"""
total_cost = position["usdc_up"] + position["usdc_down"]
if winner_side == "up":
settlement_received = position["shares_up"] * 1.00
else:
settlement_received = position["shares_down"] * 1.00
net_pnl = settlement_received - total_cost
return_pct = (net_pnl / total_cost) * 100 if total_cost > 0 else 0.0
return {
"total_cost": round(total_cost, 4),
"settlement_received": round(settlement_received, 4),
"net_pnl": round(net_pnl, 4),
"return_pct": round(return_pct, 4),
}
The one exception: unpaired legs
The no-exit rule applies only to fully paired positions. If the bot goes offline mid-window and only one leg was filled, you have naked directional exposure. That is not a paired trade and the hold-to-settlement logic does not apply.
Track pairing state per market window. If a window closes with one leg, decide whether to hold or close based on your risk tolerance. Do not let the no-exit rule prevent you from managing genuinely unhedged exposure.
Stop-losses are not a safety net here
Adding a stop-loss to a paired position does not reduce risk. It converts a locked profit into a realized loss plus an unhedged position. If you feel the urge to add stop-losses, it is a signal that you do not yet have full confidence in the entry gate. Fix the gate, not the exit.
Settlement logic is correct when this holds
After a test session, run get_session_summary() and verify that total_settled minus total_deployed equals the sum of all individual net_pnl values. If those numbers do not match, there is a bug in your settlement accounting. Also verify that your session return percentage matches the formula: net_pnl divided by total_deployed times 100. The reference benchmark is +3.79% on roughly $9,700 deployed. Your early sessions will likely be lower as you tune the gate threshold.
Monitor Edge Longevity: Session Scheduling, Risk Controls, and Capacity Limits
The spread-capture edge is not permanent. It depends on thin books, low competition, and favorable UTC hours. This final step covers how to schedule sessions around the hours where the edge is strongest, how to monitor whether the edge is eroding over time, and the hard capacity ceiling that limits how much capital this strategy can absorb before it stops working.
Paste this into your AI coding agent to work through this step. Includes both walk-me-through framing and the specific sub-tasks for this step.
I'm working through a step-by-step tutorial. I'm on Step 07: Monitor Edge Longevity: Session Scheduling, Risk Controls, and Capacity Limits. Step goal: The spread-capture edge is not permanent. It depends on thin books, low competition, and favorable UTC hours. This final step covers how to schedule sessions around the hours where the edge is strongest, how to monitor whether the edge is eroding over time, and the hard capacity ceiling that limits how much capital this strategy can absorb before it stops working. Walk me through this step interactively. Ask me clarifying questions if I'm stuck. When I write code, review it for any setup-specific gotchas before I run it. When I hit errors, quote my logs back to me with a plain-English explanation. Don't assume I know every library or API surface this step touches — point me to the right docs when I need them. Confirm I've actually completed the step before suggesting we move on. --- Specific sub-tasks to complete during this step: ## TASK 1: Generate a daily session report that surfaces all key health metrics Use this after implementing the session loop, settlement listener, and edge health monitor. It produces a structured end-of-session report. I have a Polymarket spread-capture bot with the following components: - SessionSummary: total_deployed, total_settled, net_pnl, return_pct, total_markets_entered - EdgeHealthMonitor: rolling_median(), health_status() - PairingMonitor: get_unpaired_exposure_usdc() Write a Python function called `generate_session_report(session_summary, edge_monitor, pairing_monitor)` that: 1. Prints a structured end-of-session report to stdout. 2. Includes: session date/time (UTC), total markets entered, total USDC deployed, total USDC settled, net P/L, return percentage, rolling 7-day median paired cost, edge health status, and current unpaired exposure. 3. Flags any metrics that are outside healthy ranges with a WARNING prefix: - Return below 0% is a warning. - Rolling median above 0.90 is a warning. - Any unpaired exposure above $0 is a warning. 4. Ends with a one-line GO / NO-GO recommendation for the next session based on the health status. 5. Also returns the report as a dict so it can be logged to a file or database. Format the output cleanly with aligned columns. Use only the standard library (no pandas, no external deps).
UTC session scheduling: best hours and dead hours
Not all hours are equal. The spread-capture edge is widest during active crypto trading hours when 5-minute books are being created and repriced frequently, but not so liquid that makers tighten their quotes. The dead hours are UTC 22, 23, 0, 1, 2, 3, and 8. During these windows, books are thin in a different way: the sides barely seesaw, so there is not enough activity to generate the volume of cheap-side dips needed to accumulate pairs below the ceiling.
The gate function already includes the sleep window check. But you should also implement a session-level scheduler that starts and stops the bot automatically based on UTC hour. This prevents the bot from running during dead hours even if the gate check has a bug or is bypassed.
The best active hours in UTC are roughly 9 through 21. Within that window, the bot should run continuously, scanning for new markets every 30 to 60 seconds and entering any that pass the gate. Do not try to time individual markets within the session. The strategy works by volume across many markets, not by picking the best individual windows.
import asyncio
from datetime import datetime, timezone
# UTC hours when the bot is allowed to trade
# Dead hours: 22, 23, 0, 1, 2, 3, 8
ACTIVE_HOURS_UTC = set(range(9, 22)) - {8} # 09:00 to 21:59 UTC
def is_active_hour() -> bool:
"""Return True if the current UTC hour is in the active trading window."""
return datetime.now(timezone.utc).hour in ACTIVE_HOURS_UTC
async def run_session_loop(bot, poll_interval_seconds: int = 30) -> None:
"""
Main session loop. Runs the bot during active hours and sleeps during dead hours.
Checks the active window every poll_interval_seconds.
Args:
bot: The bot instance with a run_scan_cycle() coroutine.
poll_interval_seconds: How often to scan for new markets (default: 30s).
"""
print("[scheduler] Session loop started")
while True:
if not is_active_hour():
utc_hour = datetime.now(timezone.utc).hour
print(f"[scheduler] Dead hour UTC {utc_hour:02d}:xx — sleeping 5 minutes")
await asyncio.sleep(300)
continue
try:
await bot.run_scan_cycle()
except Exception as e:
print(f"[scheduler] Scan cycle error: {e}")
# Do not crash the loop on a single cycle error
await asyncio.sleep(10)
continue
await asyncio.sleep(poll_interval_seconds)
Monitoring edge longevity
The most important health metric for this strategy is the median paired cost across all entries in a rolling 7-day window. The reference benchmark is $0.809. If your median paired cost starts drifting above $0.85 week over week, the edge is eroding. More bots are competing for the same cheap-side dips, and the books are being arbitraged tighter.
Track this number weekly. If it crosses $0.90 consistently, reduce clip size and tighten the gate threshold. If it crosses $0.95, stop trading and reassess. The edge may have closed in your market segment.
Hard capacity ceiling near $15,000 per wallet
The reference data shows a hard capacity ceiling around $15,000 of daily deployment per wallet. Above that level, clip sizes start moving the ask against you on thin books, raising your own paired cost. Expected P/L stops scaling linearly and starts declining. If you need more capacity, fragment across multiple wallets rather than increasing clip size on a single wallet.
from collections import deque
from statistics import median
from datetime import datetime, timezone, timedelta
EDGE_ALERT_THRESHOLD = 0.90 # Warn if rolling median exceeds this
EDGE_STOP_THRESHOLD = 0.95 # Stop trading if rolling median exceeds this
ROLLING_WINDOW_DAYS = 7
class EdgeHealthMonitor:
"""
Tracks rolling median paired cost to detect edge erosion.
The reference benchmark is a median of ~$0.809.
Consistent drift above $0.90 signals the edge is closing.
"""
def __init__(self):
# Store (timestamp, paired_cost) tuples
self._entries: deque = deque()
def record_entry(self, paired_cost: float) -> None:
"""Record a new paired cost observation."""
self._entries.append((datetime.now(timezone.utc), paired_cost))
self._prune_old_entries()
def _prune_old_entries(self) -> None:
cutoff = datetime.now(timezone.utc) - timedelta(days=ROLLING_WINDOW_DAYS)
while self._entries and self._entries[0][0] < cutoff:
self._entries.popleft()
def rolling_median(self) -> float | None:
if not self._entries:
return None
return median(cost for _, cost in self._entries)
def health_status(self) -> dict:
med = self.rolling_median()
if med is None:
return {"status": "no_data", "median": None, "action": "wait"}
if med >= EDGE_STOP_THRESHOLD:
return {"status": "critical", "median": med, "action": "stop_trading"}
if med >= EDGE_ALERT_THRESHOLD:
return {"status": "warning", "median": med, "action": "tighten_gate"}
return {"status": "healthy", "median": med, "action": "continue"}
Two filters that look reasonable but destroy the edge
A price band filter that excludes entries where either side is below $0.20 or above $0.80 removes the highest-ROI trades in the book. The sub-$0.20 cheap-side fills are where the widest margins live. Similarly, a dominance filter that skips entries where one side is more than 2x cheaper than the other removes the most asymmetric and most profitable entries. Both filters feel like risk management. Both cut profit by a large fraction. Do not add them.
Your bot is structurally sound when all of these are true
After your first full session: rolling median paired cost is below $0.90, session return is positive, every market you traded ended as a completed pair with no unpaired legs left open, and the balance-allowance sync ran successfully on boot. These five checks are your go/no-go gate for scaling up. Do not increase clip size or capital until all five pass consistently across at least three sessions.