Real-Time Odds Feed for Market Makers: Exchange Depth and Spread Capture
Market Makers Need Two Things: A Reference Price and a Venue
Most trading infrastructure treats those as separate problems. You build one integration for a pricing data feed and another for the exchange where you actually quote. In sports prediction markets, that means separate API calls to Pinnacle for the sharp reference price and to Polymarket or Kalshi for the depth-of-book — two feeds, two polling loops, two staleness risks.
OddsPapi’s B2B feed solves this at the schema level. A single /odds call returns sportsbook prices (Pinnacle, SBOBet, Singbet) alongside exchange depth-of-book ladders (exchangeMeta) for Polymarket, Kalshi, and Betfair — in the same JSON object, keyed by bookmaker slug. Your signal loop reads from one source. Your stale detection checks one feed.
This post builds a market making signal loop using live data from the 2026 World Cup. It is post 3 in the OddsPapi B2B series — post 1 covered the WebSocket feed architecture, post 2 covered CLV line auditing.
What Market Making Looks Like in Practice
A market maker on Polymarket or Kalshi does not pick sides. They quote both bid and ask inside the current spread, collect the bid-ask margin on each matched trade, and hedge directional risk using the sportsbook feed as the reference. The profit is not “I think Spain wins” — it is “I can quote Spain YES at 92.5¢ bid / 93.5¢ ask while the market is at 92¢/94¢, and I collect 1¢ per matched round-trip.”
Three things control whether that is profitable:
- Is the reference price accurate? If you’re quoting relative to a stale Pinnacle price, your spread is anchored to yesterday’s market.
- Is the exchange spread wide enough to quote inside? On a 1¢ spread, you cannot make a market — there is no room. On a 3-5¢ spread, there is.
- Is the feed live? If a bookmaker’s feed goes stale, your reference is unreliable and your risk is unhedged. You need to know instantly.
All three are solvable with the right feed. Here is what that looks like with real data.
Live Data: Spain vs Cape Verde, World Cup 2026
Fixture ID id1000001666456994 (Spain vs Cape Verde, Group Stage, Jun 15 2026). A single /odds call returned 17 bookmakers including Pinnacle (the sharp reference), Kalshi, and Polymarket (the exchange venues).
Step 1: Get All Books in One Call
import requests
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
r = requests.get(f"{BASE_URL}/odds", params={
"apiKey": API_KEY,
"fixtureId": "id1000001666456994",
"bookmakers": "pinnacle,kalshi,polymarket,betfair-ex,sbobet",
})
books = r.json().get("bookmakerOdds", {})
print(f"Books returned: {list(books.keys())}")
# Output: ['bet365', 'betmgm', 'kalshi', 'sbobet', 'pinnacle', 'polymarket', ...]
# 17 bookmakers in one call
Step 2: Extract the Sharp Reference (Pinnacle De-Vigged)
def devig_market(outcomes_dict):
prices = {oid: od["players"]["0"]["price"]
for oid, od in outcomes_dict.items()
if od.get("players", {}).get("0", {}).get("active")}
total_imp = sum(1/p for p in prices.values())
overround = (total_imp - 1) * 100
fair = {oid: (1/p) / total_imp for oid, p in prices.items()}
return fair, overround
# Pinnacle 1X2 (market 101) on Spain vs Cape Verde
pin_outcomes = books["pinnacle"]["markets"]["101"]["outcomes"]
fair, vig = devig_market(pin_outcomes)
print(f"Pinnacle overround: {vig:.2f}%")
for oid, prob in fair.items():
raw = pin_outcomes[oid]["players"]["0"]["price"]
print(f" Outcome {oid}: raw={raw:.3f}, no-vig fair={1/prob:.3f} ({prob*100:.1f}%)")
Live output on Spain vs Cape Verde:
Pinnacle overround: 2.01%
Outcome 101 (Spain): raw=1.083, no-vig fair=1.105 (90.5%)
Outcome 102 (Draw): raw=15.5, no-vig fair=15.812 (6.3%)
Outcome 103 (Cape Verde): raw=31.0, no-vig fair=31.624 (3.2%)
Step 3: Read the Exchange Depth-of-Book
For exchange-type bookmakers (Polymarket, Kalshi, Betfair), the players["0"] object includes an exchangeMeta field with a full depth-of-book ladder. back and lay are lists of price levels (best first), each with price (decimal odds), size (total volume at that level), limit (available to match), and cents (native share price, 0-1).
def read_ladder(books, slug, market_id, outcome_id):
try:
outcome = (books[slug]["markets"][market_id]
["outcomes"][outcome_id]["players"]["0"])
em = outcome.get("exchangeMeta") or {}
return {
"price": outcome.get("price"),
"active": outcome.get("active"),
"back": em.get("back", []),
"lay": em.get("lay", []),
}
except (KeyError, TypeError):
return None
# Kalshi Spain (outcome 101)
kalshi_spain = read_ladder(books, "kalshi", "101", "101")
print("Kalshi Spain back ladder:")
for level in kalshi_spain["back"][:3]:
print(f" {level['cents']*100:.0f}c {level['price']:.3f} ${level['size']:,.0f} available")
print("Kalshi Spain lay (NO) ladder:")
for level in kalshi_spain["lay"][:3]:
print(f" {level['cents']*100:.0f}c {level['price']:.3f} ${level['size']:,.0f} available")
Live output:
Kalshi Spain back ladder (YES — betting Spain wins):
93c 1.075 $4,772,928 available
94c 1.064 $1,852,102 available
95c 1.053 $1,301,446 available
Kalshi Spain lay (NO — betting Spain does NOT win):
8c 12.500 $104,667 available
9c 11.111 $319,527 available
10c 10.000 $177,318 available
Reading the spread: Spain YES trades at 93¢. The NO side trades at 8¢ (implying Spain wins 92% of the time at that level). Effective YES ask implied from NO: ~92¢. Bid-ask spread: 1¢ — 93¢ bid / 92¢ implied ask. In decimal odds that is 1.075 bid / 1.087 implied ask.
Compare to Polymarket’s Spain back:
Polymarket Spain back ladder:
92c 1.087 $1,647,509 available
93c 1.075 $1,650,604 available
94c 1.064 $1,789,496 available
Polymarket is quoting Spain at 92¢ best back — slightly below Kalshi’s 93¢. Both are above the Pinnacle no-vig fair of 90.5¢ (1.105), which means the prediction markets are pricing Spain as a slightly stronger favourite than Pinnacle. The spread between the sharpest exchange price (93¢) and the Pinnacle no-vig anchor (90.5¢) is the market maker’s working range.
The Market Making Signal Loop
import requests, time
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
MIN_SPREAD_CENTS = 1.5 # only make markets when spread >= 1.5c
MIN_EDGE_VS_FAIR = 0.005 # only back if exchange price > Pinnacle fair + 0.5%
def pinnacle_fair(outcomes_dict):
prices = {}
for oid, od in outcomes_dict.items():
p0 = od.get("players", {}).get("0", {})
if p0.get("active") and p0.get("price"):
prices[oid] = p0["price"]
total = sum(1/p for p in prices.values())
return {oid: (1/p) / total for oid, p in prices.items()}
def mm_signal(fair_probs, back_ladder, lay_ladder, outcome_id):
if not back_ladder:
return None
fair_prob = fair_probs.get(outcome_id)
if not fair_prob:
return None
fair_price = 1 / fair_prob
fair_cents = fair_prob # share price equivalent
best_back_cents = back_ladder[0]["cents"]
best_back_price = back_ladder[0]["price"]
best_back_size = back_ladder[0]["size"]
# Derive implied YES ask from NO ladder (if available)
if lay_ladder:
no_best_cents = lay_ladder[0]["cents"]
implied_yes_ask_cents = 1.0 - no_best_cents
spread_cents = best_back_cents - implied_yes_ask_cents
else:
spread_cents = None
# Edge: exchange back price vs Pinnacle fair
edge_vs_fair = (best_back_price / fair_price) - 1
return {
"outcome_id": outcome_id,
"fair_price": round(fair_price, 3),
"fair_cents": round(fair_cents * 100, 1),
"exchange_back": best_back_price,
"exchange_back_cents": round(best_back_cents * 100, 1),
"exchange_size": best_back_size,
"spread_cents": round(spread_cents * 100, 2) if spread_cents else None,
"edge_vs_fair_pct": round(edge_vs_fair * 100, 2),
"signal": (
"QUOTE" if (spread_cents and spread_cents * 100 >= MIN_SPREAD_CENTS
and edge_vs_fair > MIN_EDGE_VS_FAIR)
else "WATCH"
),
}
def run_mm_loop(fixture_id, market_id="101", poll_seconds=5):
print(f"Starting MM loop on fixture {fixture_id}, market {market_id}")
stale = {}
while True:
r = requests.get(f"{BASE_URL}/odds", params={
"apiKey": API_KEY, "fixtureId": fixture_id,
"bookmakers": "pinnacle,kalshi,polymarket",
})
if r.status_code != 200:
time.sleep(poll_seconds)
continue
books = r.json().get("bookmakerOdds", {})
# Pinnacle reference (check stale via bookmakerChangedAt freshness)
if "pinnacle" not in books:
print("Pinnacle not present — pausing")
time.sleep(poll_seconds)
continue
pin_outcomes = books["pinnacle"]["markets"].get(market_id, {}).get("outcomes", {})
fair = pinnacle_fair(pin_outcomes)
for exchange in ["kalshi", "polymarket"]:
if exchange not in books:
continue
ex_market = books[exchange]["markets"].get(market_id, {})
ex_outcomes = ex_market.get("outcomes", {})
for oid in fair:
if oid not in ex_outcomes:
continue
p0 = ex_outcomes[oid]["players"]["0"]
em = p0.get("exchangeMeta") or {}
signal = mm_signal(
fair_probs=fair,
back_ladder=em.get("back", []),
lay_ladder=em.get("lay", []),
outcome_id=oid,
)
if signal and signal["signal"] == "QUOTE":
print(f"[{exchange}] Outcome {oid}: QUOTE signal")
print(f" Fair: {signal['fair_cents']}c ({signal['fair_price']})")
print(f" Exchange back: {signal['exchange_back_cents']}c, edge={signal['edge_vs_fair_pct']:+.2f}%")
print(f" Spread: {signal['spread_cents']}c, size=${signal['exchange_size']:,.0f}")
time.sleep(poll_seconds)
# Run on Spain vs Cape Verde
run_mm_loop("id1000001666456994")
staleOdds: The Kill Switch for Your Positions
A market maker’s risk is directional exposure. If you have open YES positions on Spain and the Pinnacle feed goes stale, your reference price is frozen — you cannot know if the market has moved against you. The correct response is to widen your quotes immediately (to reduce trade probability) or withdraw them entirely.
The staleOdds flag on the B2B WebSocket feed (docs.oddspapi.io) delivers this signal at the bookmaker level. On the REST API, you can approximate it by tracking bookmakerChangedAt — the timestamp of the last price change from the source bookmaker:
from datetime import datetime, timezone, timedelta
STALE_THRESHOLD_SECONDS = 120 # treat as stale if unchanged for 2 minutes
def is_stale(outcome_obj, threshold_s=STALE_THRESHOLD_SECONDS):
changed_at = outcome_obj.get("bookmakerChangedAt")
if not changed_at:
return True # unknown = treat as stale
last = datetime.fromisoformat(changed_at.replace("Z", "+00:00"))
age = (datetime.now(timezone.utc) - last).total_seconds()
return age > threshold_s
# In the signal loop:
p0 = pin_outcomes.get(oid, {}).get("players", {}).get("0", {})
if is_stale(p0):
print(f"Pinnacle {oid} is stale — withdrawing quotes")
continue
On the B2B WebSocket feed, the bookmakers channel delivers staleOdds: true the moment the aggregation layer detects a connectivity problem — no polling required. For a market making system running 24/7 on live in-play markets, the WebSocket signal is materially faster than any polling approximation. See the WebSocket feed architecture post for the full stale detection pattern.
What the Depth Tells You Beyond the Spread
The back ladder is not just a spread signal — it tells you how much you can trade at each price level before moving the market. On Spain vs Cape Verde at kickoff window, the Kalshi Spain YES depth was:
| Price level | Share price | Volume available | vs Pinnacle fair |
|---|---|---|---|
| 1.075 (best) | 93¢ | $4,772,928 | +2.5c above fair (90.5¢) |
| 1.064 (2nd) | 94¢ | $1,852,102 | +3.5c above fair |
| 1.053 (3rd) | 95¢ | $1,301,446 | +4.5c above fair |
Over $7M of liquidity within 4.5¢ of fair on a single WC game outcome. For a market maker sizing positions against this ladder, limit (available to match) is the relevant field — not size (total volume at the level). A level with $4.7M size but only $24K limit means most of the size is already matched; you can only take the residual.
def available_at_level(level):
return level.get("limit", 0) # NOT "size" — size includes already-matched volume
def total_available_to_price(back_ladder, max_price_decimal):
return sum(
available_at_level(lvl) for lvl in back_ladder
if lvl["price"] >= max_price_decimal
)
# How much can I back Spain at 1.064 or better on Kalshi?
total = total_available_to_price(kalshi_spain["back"], max_price_decimal=1.064)
print(f"Available to back Spain at >=1.064 on Kalshi: ${total:,.0f}")
350+ Books as the Price Discovery Engine
The market making signal above uses Pinnacle as the single reference. In practice, you want a consensus across multiple sharp books to reduce the risk of a single-book move triggering a false signal. Pull Pinnacle, SBOBet, and Singbet in the same call — all three are in the OddsPapi catalog — and use the median no-vig fair as your anchor:
def consensus_fair(books, market_id, outcome_id, sharp_books=("pinnacle", "sbobet", "singbet")):
fair_probs = []
for slug in sharp_books:
if slug not in books:
continue
outcomes = books[slug].get("markets", {}).get(market_id, {}).get("outcomes", {})
fair, _ = devig_market(outcomes)
if outcome_id in fair:
fair_probs.append(fair[outcome_id])
if not fair_probs:
return None
# Median consensus — less sensitive to a single book moving
sorted_probs = sorted(fair_probs)
n = len(sorted_probs)
return sorted_probs[n // 2]
With OddsPapi’s 200+ bookmakers in a single feed, a three-sharp consensus is one API call. Without the aggregated feed, it would be three separate integrations with three separate rate limits and three staleness clocks to manage.
Overlap with the Arb Bot Pattern
Market making and arbitrage use the same underlying data but different logic. The arb bot looks for simultaneous mispricing across books that guarantees a profit regardless of outcome. A market maker does not wait for a guaranteed spread — they quote continuously and manage directional risk through hedging, not lock-in.
The practical difference: an arb opportunity appears and disappears in seconds. A market making spread exists continuously — the money is made on volume of matched trades, not on single locked-in positions. Both strategies depend on sub-second reference price updates; the arb dies if the price moves before you execute, and the MM’s hedge leg fails if the reference goes stale while positions are open.
The Polymarket and Kalshi vs sportsbooks guide covers the exchange architecture in more depth if you are new to how prediction market order books work.
Frequently Asked Questions
What is market making in sports prediction markets?
Market making means continuously quoting both sides (buy and sell / back and lay) on an exchange, collecting the bid-ask spread on each matched trade. The market maker uses a sharp sportsbook feed (Pinnacle no-vig) as the reference price and hedges directional risk rather than picking winners.
How do I read the exchangeMeta back/lay ladder?
The back list contains price levels at which you can back (bet YES) an outcome. Each level has price (decimal odds), cents (native share price, 0-1), size (total volume at the level), and limit (available to match — use this, not size, for position sizing). The lay list is the NO side. Best available price is always the first element.
How many exchange bookmakers does OddsPapi cover?
Polymarket, Kalshi, Betfair Exchange, SX Bet, ProphetX, Limitless, Myriad, Matchbook, and several Asian exchanges are in the catalog. Coverage varies by sport and fixture — prediction markets (Polymarket, Kalshi) are deepest on major events like the World Cup, elections, and high-profile US sports.
What is the staleOdds flag and why does it matter for market making?
When OddsPapi loses connectivity to a source bookmaker, it sets staleOdds: true on the bookmakers WebSocket channel for that bookmaker. For a market maker using Pinnacle as the reference, a stale Pinnacle feed means your anchor price is frozen — you cannot safely price your quotes. The correct response is to widen or withdraw quotes until the flag clears.
Does this require the B2B feed or can I use the free tier?
The /odds endpoint with exchangeMeta depth-of-book is accessible on the free v4 API key. The B2B WebSocket feed (docs.oddspapi.io) adds real-time push delivery (no polling), the staleOdds flag on the bookmakers channel, and higher throughput — necessary for a production market making system running on live in-play markets. The polling approach shown here works for testing and strategy development.