Consensus Odds: How to Calculate Fair Odds from 350+ Bookmakers

Consensus Odds - OddsPapi API Blog
How To Guides May 14, 2026

What Are the True Odds? 350+ Bookmakers Have the Answer

Every bookmaker bakes a margin into their odds. That margin — the vig — means no single book shows you the “true” probability of an outcome. But what if you could strip the vig from 100+ bookmakers and average the result?

That’s consensus odds: the market-implied fair price after removing each bookmaker’s margin and aggregating across every available source. It’s the closest thing to “true odds” that exists — and the more bookmakers you include, the more stable and accurate the estimate becomes.

The problem? Most odds APIs give you 40-85 bookmakers. That’s a noisy sample. At 130+ books — including sharps like Pinnacle, Singbet, and SBOBet — you get a genuine market consensus that outliers can’t distort. This tutorial shows you how to calculate it in Python with free data from OddsPapi.

Why Sample Size Matters for Consensus Odds

Books in Sample Median Stability Outlier Resistance Vig Range Captured Consensus Quality
5 Low — one book shifts result None 2-5% Unreliable
40 (The Odds API) Moderate Some 2-8% Decent estimate
85 (SportsGameOdds) Better Good 2-12% Reasonable
130+ (OddsPapi) High — adding books barely moves it Excellent 0.8-18%+ True market consensus

With 130+ bookmakers including sharp books, exchange prices, and regional soft books, the median converges to a stable fair price. Remove 10 books? The result barely changes. That’s the definition of robust.

Old Way vs OddsPapi

Feature Scraping The Odds API SportsGameOdds OddsPapi
Bookmakers 2-3 you scrape ~40 ~85 350+
Sharp books No (blocked) No Limited Pinnacle, Singbet, SBOBet
Exchange odds No No No Betfair Exchange
Typical vig range 5-8% 3-8% 2-10% 0.8-18%+
Consensus quality Useless Decent Reasonable True market consensus
Free tier IP bans 500 req/mo Limited 250 req/mo + historical

Step 1: Strip the Vig

Before you can calculate consensus odds, you need to understand vig removal. Every bookmaker’s implied probabilities sum to more than 100% — the excess is their margin.

Basic Conversion: Odds to Implied Probability

def implied_prob(decimal_odds):
    """Convert decimal odds to implied probability."""
    return 1 / decimal_odds

# Pinnacle on Chelsea vs Man City (1X2):
#   Home: 3.60  Draw: 4.15  Away: 1.98
probs = [implied_prob(3.60), implied_prob(4.15), implied_prob(1.98)]
print(f"Implied probs: {[f'{p:.4f}' for p in probs]}")
print(f"Sum: {sum(probs):.4f}")
print(f"Overround (vig): {(sum(probs) - 1) * 100:.2f}%")
# Implied probs: ['0.2778', '0.2410', '0.5051']
# Sum: 1.0238
# Overround (vig): 2.38%

Pinnacle’s implied probabilities sum to 102.38% — that extra 2.38% is the vig. For a soft book like 888sport (vig ~8%), the distortion is much larger. To find fair odds, we need to remove it.

Multiplicative Method (The Default)

The simplest and most common approach: divide each implied probability by the total. Three lines of code, accurate for 95% of use cases.

def remove_vig_multiplicative(probs):
    """Remove vig by proportional normalization."""
    total = sum(probs)
    return [p / total for p in probs]

fair = remove_vig_multiplicative(probs)
print(f"Fair probs: {[f'{p:.4f}' for p in fair]}")
print(f"Fair odds:  {[f'{1/p:.3f}' for p in fair]}")
# Fair probs: ['0.2713', '0.2354', '0.4933']
# Fair odds:  ['3.686', '4.249', '2.027']

Pinnacle’s 2.38% vig is now gone. The fair odds are slightly higher than the raw odds — that’s the margin that was baked in.

Power Method (Optional Upgrade for Quants)

The multiplicative method distributes vig proportionally across all outcomes. The power method (sometimes called the Shin method) adjusts vig non-linearly — favourites lose less vig, longshots lose more. This is more theoretically sound because bookmakers tend to shade longshot prices more heavily.

def remove_vig_power(probs):
    """Remove vig using power method. More accurate for lopsided markets."""
    lo, hi = 1.0, 100.0
    for _ in range(200):  # binary search for exponent k
        k = (lo + hi) / 2
        if sum(p ** k for p in probs) > 1.0:
            lo = k
        else:
            hi = k
    k = (lo + hi) / 2
    fair = [p ** k for p in probs]
    s = sum(fair)
    return [p / s for p in fair]

fair_power = remove_vig_power(probs)
print(f"Power fair odds: {[f'{1/p:.3f}' for p in fair_power]}")
# Power fair odds: ['3.708', '4.289', '2.011']

For Pinnacle’s tight 2.38% vig, the difference between methods is small (a few cents on the odds). For high-vig soft books (8-15%), the power method diverges more. Use multiplicative as your default — upgrade to power if you’re building a serious model.

Step 2: Pull Odds from 350+ Bookmakers

Setup and API Helper

import requests
import time
import statistics
from datetime import datetime, timedelta

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"

OUTCOME_MAP = {"101": "Home", "102": "Draw", "103": "Away"}
SHARP_BOOKS = {"pinnacle", "singbet", "sbobet"}

def api_get(endpoint, params=None):
    """Authenticated API call."""
    if params is None:
        params = {}
    params["apiKey"] = API_KEY
    r = requests.get(f"{BASE_URL}/{endpoint}", params=params)
    r.raise_for_status()
    return r.json()

Find Fixtures

def get_fixtures(sport_id, days_ahead=7):
    """Fetch upcoming fixtures with odds."""
    today = datetime.utcnow().strftime("%Y-%m-%d")
    end = (datetime.utcnow() + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
    fixtures = api_get("fixtures", {
        "sportId": sport_id, "from": today, "to": end
    })
    now = datetime.utcnow()
    return [
        f for f in fixtures
        if f.get("hasOdds")
        and datetime.fromisoformat(f["startTime"].replace("Z", "")) > now
    ]

fixtures = get_fixtures(sport_id=10)  # Soccer
print(f"Fixtures with odds: {len(fixtures)}")
for f in fixtures[:3]:
    print(f"  {f['participant1Name']} vs {f['participant2Name']}")

Fetch ALL Bookmaker Odds

This is the key difference from other tutorials: we fetch every available bookmaker by omitting the bookmakers parameter. No filtering, no cherry-picking — the full market.

def fetch_all_odds(fixture_id, market_id="101"):
    """Fetch odds from ALL bookmakers for a fixture. Returns flat list of dicts."""
    data = api_get("odds", {"fixtureId": fixture_id})
    bo = data.get("bookmakerOdds", {})

    rows = []
    for slug, bdata in bo.items():
        market = bdata.get("markets", {}).get(market_id, {})
        outcomes = market.get("outcomes", {})
        prices = {}
        for oid in OUTCOME_MAP:
            p0 = outcomes.get(oid, {}).get("players", {}).get("0", {})
            if isinstance(p0, dict) and p0.get("active") and p0.get("price"):
                prices[oid] = p0["price"]
        if len(prices) == len(OUTCOME_MAP):
            vig = sum(1 / p for p in prices.values()) - 1
            if vig < 0.20:  # filter broken feeds (>20% vig)
                rows.append({"slug": slug, "prices": prices, "vig": vig * 100})

    return rows

# Example: Chelsea FC vs Manchester City
odds = fetch_all_odds("id1000001761301147")
print(f"Bookmakers with valid 1X2 prices: {len(odds)}")
# Bookmakers with valid 1X2 prices: 115

115 bookmakers with active 1X2 prices on a single Premier League match. The vig ranges from 0.84% (Betfair Exchange) to 18%+ (regional soft books). That spread is exactly what makes consensus calculation powerful — you’re sampling the full market, not a curated subset.

Step 3: Four Ways to Calculate Consensus Odds

Method A: Simple Median

The median is the most robust starting point. It’s resistant to outliers (broken feeds, stale prices) and requires zero configuration. Always aggregate in probability space, not odds space — decimal odds are non-linear.

def consensus_median(odds_rows):
    """Median of implied probabilities across all bookmakers."""
    probs_by_outcome = {oid: [] for oid in OUTCOME_MAP}

    for row in odds_rows:
        for oid, price in row["prices"].items():
            probs_by_outcome[oid].append(1 / price)

    raw = {oid: statistics.median(ps) for oid, ps in probs_by_outcome.items()}
    total = sum(raw.values())
    return {oid: p / total for oid, p in raw.items()}

fair = consensus_median(odds)
for oid, label in OUTCOME_MAP.items():
    print(f"  {label}: {fair[oid]:.4f} (fair odds: {1/fair[oid]:.3f})")
# Home: 0.2680 (fair odds: 3.731)
# Draw: 0.2440 (fair odds: 4.099)
# Away: 0.4880 (fair odds: 2.049)

Method B: Sharp-Weighted Mean

Not all bookmakers are created equal. Pinnacle, Singbet, and SBOBet are “sharp” — they accept the highest limits from professional bettors, so their prices reflect the most informed money in the market. Weighting sharps 3x gives them outsized influence without ignoring the soft book signal entirely.

def consensus_sharp_weighted(odds_rows, sharp_weight=3, soft_weight=1):
    """Weighted mean: sharp books count more."""
    weighted = {oid: [] for oid in OUTCOME_MAP}

    for row in odds_rows:
        w = sharp_weight if row["slug"] in SHARP_BOOKS else soft_weight
        for oid, price in row["prices"].items():
            weighted[oid].extend([1 / price] * w)

    raw = {oid: statistics.mean(ps) for oid, ps in weighted.items()}
    total = sum(raw.values())
    return {oid: p / total for oid, p in raw.items()}

fair = consensus_sharp_weighted(odds)
for oid, label in OUTCOME_MAP.items():
    print(f"  {label}: {fair[oid]:.4f} (fair odds: {1/fair[oid]:.3f})")
# Home: 0.2741 (fair odds: 3.649)
# Draw: 0.2443 (fair odds: 4.093)
# Away: 0.4816 (fair odds: 2.076)

Notice the sharp-weighted result pulls slightly toward Pinnacle’s prices (3.60 / 4.15 / 1.98) compared to the raw median. That’s the intended effect — sharps are better price-setters than soft books.

Method C: Vig-Removed Consensus (Gold Standard)

The cleanest approach: strip the vig from each bookmaker individually, then take the median of the resulting fair probabilities. This removes the distortion that high-vig soft books inject into the consensus.

def consensus_vig_removed(odds_rows):
    """Remove each book's vig first, then take median of fair probs."""
    fair_probs = {oid: [] for oid in OUTCOME_MAP}

    for row in odds_rows:
        implied = [1 / row["prices"][oid] for oid in OUTCOME_MAP]
        fair = remove_vig_multiplicative(implied)
        for i, oid in enumerate(OUTCOME_MAP):
            fair_probs[oid].append(fair[i])

    raw = {oid: statistics.median(ps) for oid, ps in fair_probs.items()}
    total = sum(raw.values())
    return {oid: p / total for oid, p in raw.items()}

fair = consensus_vig_removed(odds)
for oid, label in OUTCOME_MAP.items():
    print(f"  {label}: {fair[oid]:.4f} (fair odds: {1/fair[oid]:.3f})")
# Home: 0.2677 (fair odds: 3.734)
# Draw: 0.2448 (fair odds: 4.084)
# Away: 0.4872 (fair odds: 2.052)

This is the recommended method for serious work. By devigging each book before aggregating, you’re comparing apples to apples — 115 independent estimates of the true probability, all free of margin distortion.

Method D: Exchange-Anchored

Exchanges (Betfair) charge commission on winnings, not vig on odds. Their back price is close to the true price — especially for liquid markets. Use it as a ground truth benchmark.

def consensus_exchange_anchored(odds_rows, exchange_slug="betfair-ex"):
    """Use exchange back price as fair odds (minimal vig)."""
    for row in odds_rows:
        if row["slug"] == exchange_slug:
            implied = [1 / row["prices"][oid] for oid in OUTCOME_MAP]
            fair = remove_vig_multiplicative(implied)
            return {oid: fair[i] for i, oid in enumerate(OUTCOME_MAP)}
    return None

fair = consensus_exchange_anchored(odds)
if fair:
    for oid, label in OUTCOME_MAP.items():
        print(f"  {label}: {fair[oid]:.4f} (fair odds: {1/fair[oid]:.3f})")
# Home: 0.2654 (fair odds: 3.768)
# Draw: 0.2370 (fair odds: 4.220)
# Away: 0.4976 (fair odds: 2.010)

Caveat: Betfair liquidity varies wildly by market. For Premier League 1X2, it’s deep — the exchange price is reliable. For lower-league soccer or niche sports, the vig-removed consensus from 130+ books is often more accurate than a single thinly-traded exchange price.

Step 4: Practical Applications

Find Value Bets

Compare any bookmaker’s odds to the consensus fair price. If the bookmaker is offering more than the true price, that’s a value bet.

def find_value_bets(odds_rows, consensus, min_edge=2.0):
    """Flag bookmakers offering above-consensus prices."""
    value = []
    for row in odds_rows:
        for oid, label in OUTCOME_MAP.items():
            book_price = row["prices"][oid]
            fair_price = 1 / consensus[oid]
            edge = (book_price / fair_price - 1) * 100
            if edge >= min_edge:
                value.append({
                    "book": row["slug"],
                    "outcome": label,
                    "price": book_price,
                    "fair": round(fair_price, 3),
                    "edge": round(edge, 1),
                })
    return sorted(value, key=lambda x: -x["edge"])

consensus = consensus_vig_removed(odds)
bets = find_value_bets(odds, consensus, min_edge=2.0)
print(f"Value bets found: {len(bets)}\n")
for b in bets[:8]:
    print(f"  {b['book']:20s} {b['outcome']:5s} @ {b['price']:.3f}  "
          f"(fair: {b['fair']:.3f}, edge: +{b['edge']}%)")

Line Shopping Report

Rank bookmakers by who’s offering the best price on each outcome.

def line_shopping_report(odds_rows):
    """Find best and worst prices per outcome across all books."""
    for oid, label in OUTCOME_MAP.items():
        prices = [(r["slug"], r["prices"][oid]) for r in odds_rows]
        prices.sort(key=lambda x: -x[1])
        best = prices[0]
        worst = prices[-1]
        spread = ((best[1] / worst[1]) - 1) * 100
        print(f"  {label}:")
        print(f"    Best:  {best[0]:20s} @ {best[1]:.3f}")
        print(f"    Worst: {worst[0]:20s} @ {worst[1]:.3f}")
        print(f"    Spread: {spread:.1f}%")

line_shopping_report(odds)

Market Efficiency Score

How much do bookmakers disagree? High standard deviation = high disagreement = more opportunity.

def market_efficiency(odds_rows):
    """Standard deviation of implied probs across books."""
    for oid, label in OUTCOME_MAP.items():
        probs = [1 / r["prices"][oid] for r in odds_rows]
        sd = statistics.stdev(probs) * 100
        print(f"  {label}: std dev = {sd:.2f}pp across {len(probs)} books")

market_efficiency(odds)
# Home: std dev = 2.74pp across 115 books
# Draw: std dev = 0.96pp across 115 books
# Away: std dev = 2.09pp across 115 books

The Draw market shows the tightest spread (0.96pp) — bookmakers broadly agree on the draw probability. Home has the widest disagreement (2.74pp), meaning there’s more opportunity to find mispriced Home odds.

Complete Consensus Odds Scanner

Here’s the full script combining everything above into a single runnable scanner.

import requests, time, statistics
from datetime import datetime, timedelta

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
OUTCOME_MAP = {"101": "Home", "102": "Draw", "103": "Away"}
SHARP_BOOKS = {"pinnacle", "singbet", "sbobet"}

def api_get(endpoint, params=None):
    if params is None: params = {}
    params["apiKey"] = API_KEY
    r = requests.get(f"{BASE_URL}/{endpoint}", params=params)
    r.raise_for_status()
    return r.json()

def implied_prob(odds): return 1 / odds

def remove_vig(probs):
    total = sum(probs)
    return [p / total for p in probs]

def fetch_all_odds(fixture_id, market_id="101"):
    data = api_get("odds", {"fixtureId": fixture_id})
    rows = []
    for slug, bdata in data.get("bookmakerOdds", {}).items():
        market = bdata.get("markets", {}).get(market_id, {})
        outcomes = market.get("outcomes", {})
        prices = {}
        for oid in OUTCOME_MAP:
            p0 = outcomes.get(oid, {}).get("players", {}).get("0", {})
            if isinstance(p0, dict) and p0.get("active") and p0.get("price"):
                prices[oid] = p0["price"]
        if len(prices) == len(OUTCOME_MAP):
            vig = sum(1/p for p in prices.values()) - 1
            if vig < 0.20:
                rows.append({"slug": slug, "prices": prices, "vig": vig * 100})
    return rows

def consensus_vig_removed(rows):
    fair_probs = {oid: [] for oid in OUTCOME_MAP}
    for row in rows:
        implied = [1 / row["prices"][oid] for oid in OUTCOME_MAP]
        fair = remove_vig(implied)
        for i, oid in enumerate(OUTCOME_MAP):
            fair_probs[oid].append(fair[i])
    raw = {oid: statistics.median(ps) for oid, ps in fair_probs.items()}
    total = sum(raw.values())
    return {oid: p / total for oid, p in raw.items()}

def find_value(rows, consensus, min_edge=2.0):
    value = []
    for row in rows:
        for oid, label in OUTCOME_MAP.items():
            fair_price = 1 / consensus[oid]
            edge = (row["prices"][oid] / fair_price - 1) * 100
            if edge >= min_edge:
                value.append({
                    "book": row["slug"], "outcome": label,
                    "price": row["prices"][oid],
                    "fair": round(fair_price, 3),
                    "edge": round(edge, 1),
                })
    return sorted(value, key=lambda x: -x["edge"])

# --- Run the scanner ---
today = datetime.utcnow().strftime("%Y-%m-%d")
end = (datetime.utcnow() + timedelta(days=3)).strftime("%Y-%m-%d")
fixtures = api_get("fixtures", {"sportId": 10, "from": today, "to": end})
upcoming = [f for f in fixtures if f.get("hasOdds")
            and datetime.fromisoformat(f["startTime"].replace("Z","")) > datetime.utcnow()]

print("=" * 62)
print("  CONSENSUS ODDS SCANNER")
print(f"  {len(upcoming)} fixtures | Method: vig-removed median")
print("=" * 62)

for f in upcoming[:5]:
    match = f"{f['participant1Name']} vs {f['participant2Name']}"
    print(f"\n  {match}")
    print(f"  {'-' * len(match)}")

    odds = fetch_all_odds(f["fixtureId"])
    if len(odds) < 10:
        print(f"  Skipped — only {len(odds)} books")
        continue

    consensus = consensus_vig_removed(odds)
    print(f"  Books: {len(odds)} | Consensus fair odds:")
    for oid, label in OUTCOME_MAP.items():
        print(f"    {label}: {1/consensus[oid]:.3f} (prob: {consensus[oid]*100:.1f}%)")

    value = find_value(odds, consensus, min_edge=2.0)
    if value:
        print(f"  Value bets ({len(value)}):")
        for v in value[:5]:
            print(f"    {v['book']:18s} {v['outcome']:5s} "
                  f"@ {v['price']:.3f} vs {v['fair']:.3f} (+{v['edge']}%)")

    time.sleep(2)  # respect rate limits

Why 350+ Bookmakers Changes the Game

The maths is simple: more independent price sources = more stable consensus. Here's what changes as you add bookmakers:

Metric 5 Books 40 Books 85 Books 130+ Books
Median stability Moves 5%+ if you drop one Moves ~1% Moves ~0.5% Moves < 0.2%
Outlier resistance One stale feed ruins it Moderate Good Immune — outliers vanish
Sharp books included Maybe 1 Maybe 1 1-2 3+ (Pinnacle, Singbet, SBOBet)
Exchange prices No No No Yes (Betfair)
Regional books 0 5-10 20-30 80+ (Brazil, Asia, Africa)

OddsPapi returns 130+ bookmakers with active odds on a single Premier League match. That includes 3 sharp books, Betfair Exchange, and 80+ regional soft books that no other API covers. The consensus from that sample isn't just "better" — it's a fundamentally different signal.

Frequently Asked Questions

What's the difference between consensus odds and true odds?

"True odds" are the actual probability of an outcome — unknowable in advance. Consensus odds are the best available estimate, calculated by aggregating many bookmakers' prices after removing their margins. With 130+ books, the consensus converges close to the true probability.

Which consensus method should I use?

Start with the vig-removed median (Method C). It combines the outlier resistance of the median with the accuracy of vig removal. If you want to lean on sharp book intelligence, use sharp-weighted mean (Method B). For liquid markets where Betfair is deep, exchange-anchored (Method D) is a useful cross-check.

Does this work for live/in-play betting?

Yes, but you need faster data. The REST API adds ~1 second of latency per request. For in-play consensus, use the OddsPapi WebSocket feed to stream price changes from all bookmakers simultaneously, then recalculate consensus on each update.

How does consensus compare to Pinnacle's closing line?

Academic research shows Pinnacle's closing line is one of the most efficient price-setters. But Pinnacle is one bookmaker. Consensus from 130+ books (including Pinnacle) captures information that even Pinnacle might not fully reflect — especially from regional sharp markets in Asia and South America. Think of Pinnacle as one expert opinion; consensus is the wisdom of crowds.

Do I need to remove vig before calculating consensus?

For a quick estimate, simple median (Method A) works fine. But for precision — especially if you're using the consensus to identify value bets — yes, remove vig first. A book with 8% vig will systematically pull the consensus toward shorter odds if you don't devig it.

Can I use this for sports other than soccer?

Yes. Change the sportId and OUTCOME_MAP to match the market. For NBA/NFL/MLB moneylines (2-outcome), use market ID 141 with outcomes {"141": "Home", "142": "Away"}. For tennis, use market 171. Query /v4/markets?sportId=X to discover all available markets.

Stop Guessing at True Odds. Calculate Them.

Every code example in this tutorial runs against the live OddsPapi API. Copy the complete scanner, plug in your free API key, and start computing consensus odds from 130+ bookmakers — sharps, exchanges, and soft books included.

Related guides: