DEV Community

Cover image for Build a +EV Bet Finder: Calculate No-Vig Fair Odds Using Any Odds API
orbistats
orbistats

Posted on

Build a +EV Bet Finder: Calculate No-Vig Fair Odds Using Any Odds API

Every bookmaker price contains two things: an opinion about how likely an outcome is, and a margin (the "vig" or "juice") that guarantees the bookmaker a profit over time. If you can strip out the margin, you are left with the bookmaker's real probability estimate, called the no-vig fair price. Compare that fair price with what other bookmakers are offering, and you can spot bets where the price is better than the true odds. Those are +EV (positive expected value) bets.

In this tutorial we will build a +EV finder in Python from scratch. You will learn how to remove the vig with three different methods, how to build a consensus fair price from multiple bookmakers, how to calculate expected value and Kelly stakes, and how to track whether your picks actually beat the closing line.

I will use Orbistats as the data source, because its Odds API returns normalized odds in one schema across 13 sports: Football, Basketball, American Football, Cricket, Tennis, Baseball, Esports, Combat Sports, Volleyball, Handball, Ice Hockey, Golf and Horse Racing. But every function below works with any odds feed. You only need to change one small normalizer.

Please read this first: a +EV bet is not a guaranteed win. It is a bet that should make money on average across many repetitions, and short-term variance can be brutal. Fair odds are an estimate, not a fact, and bookmakers can limit or close accounts. This article is for education and engineering practice, not betting advice. Check your local laws and each bookmaker's terms.

WHAT IS VIG, AND WHY DOES IT MATTER?

Suppose a tennis match is priced at 1.91 for Player A and 1.91 for Player B. The implied probability of each price is 1 / 1.91 = 52.36%. Add both and you get 104.71%. Real probabilities must add up to exactly 100%, so that extra 4.71% is the bookmaker's margin.

If you remove the margin evenly, each player is a true 50% chance, and the fair price is 2.00. Now imagine a different bookmaker offers 2.10 on Player A. The expected value of a 1 unit bet is:

EV = probability x odds - 1 = 0.50 x 2.10 - 1 = +0.05

That is a +5% expected return per unit staked. Finding that kind of gap automatically, across thousands of markets, is exactly what our scanner will do.

WHAT WE ARE BUILDING

Odds helpers and three vig-removal methods
A consensus fair-price engine that can weight sharper bookmakers more heavily
An EV and Kelly calculator
A scanner that compares every bookmaker's price against a fair price built from the other bookmakers
A closing-line-value tracker, plus a WebSocket upgrade for live markets

PREREQUISITES

You need Python 3.10 or newer and a free API key. You can create a free key here with no card, and if you want to inspect raw JSON before writing code, the public API Sandbox shows realistic requests and responses.

bash
mkdir ev-finder && cd ev-finder
python -m venv .venv && source .venv/bin/activate
pip install requests python-dotenv websockets

Your .env file:

bash
ORBISTATS_API_KEY=your_key_here
ORBISTATS_WS_URL=wss://your-websocket-url-from-the-docs

Authentication is a Bearer key against the versioned base URL https://api.orbistats.com/v1/. The documentation explains the sport, resource and endpoint pattern, and the developer hub links to code examples and the status page.

STEP 1: ODDS HELPERS

Bookmakers publish decimal, American or fractional odds. Convert everything to decimal first, and only then do maths.

python
def american_to_decimal(american: float) -> float:
if american > 0:
return 1 + american / 100
return 1 + 100 / abs(american)

def implied_prob(decimal_odds: float) -> float:
return 1 / decimal_odds

def overround(prices: list[float]) -> float:
"""Total implied probability minus 1, so 0.047 means a 4.7% margin."""
return sum(1 / p for p in prices) - 1

If your feed already returns decimal odds, american_to_decimal is not needed, but it is handy when you plug in other sources.

STEP 2: THREE WAYS TO REMOVE THE VIG

There is no single correct method. Each one makes a different assumption about where the bookmaker hid the margin.

Multiplicative (proportional): divide each implied probability by the total. Simple and the most common, but it assumes the margin is spread proportionally across all outcomes.

Additive: subtract an equal slice of the margin from every outcome. It can go negative on big longshots, so we guard against that.

Power method: raise each implied probability to a power k until the total equals 1. This takes more margin from longshots than from favorites, which matches the well-known favorite-longshot bias better.

python
def devig_multiplicative(prices: list[float]) -> list[float]:
q = [1 / p for p in prices]
total = sum(q)
return [x / total for x in q]

def devig_additive(prices: list[float]) -> list[float] | None:
q = [1 / p for p in prices]
slice_ = (sum(q) - 1) / len(q)
fair = [x - slice_ for x in q]
return fair if all(f > 0 for f in fair) else None

def devig_power(prices: list[float]) -> list[float]:
q = [1 / p for p in prices]
lo, hi = 1.0, 10.0
for _ in range(100):
k = (lo + hi) / 2
if sum(x ** k for x in q) > 1:
lo = k
else:
hi = k
k = (lo + hi) / 2
return [x ** k for x in q]

Quick check with a lopsided market priced 1.50 and 2.60. The implied probabilities are 66.67% and 38.46%, which total 105.13%. The multiplicative method gives roughly 63.4% and 36.6%, and the power method shaves slightly more off the longshot. Run all three on real markets and compare. When they disagree wildly, the market is usually thin or the margin is unusually large.

For most projects, start with the multiplicative or power method and stay consistent, since you will compare results against your own history.

STEP 3: BUILD A CONSENSUS FAIR PRICE

A single bookmaker's no-vig price is noisy. A much better estimate comes from combining several bookmakers, with more weight on the ones that have the sharpest lines (usually those that accept large bets and rarely limit winners).

python
def consensus_fair_probs(books, outcomes, weights=None, devig=devig_power):
"""books maps bookmaker -> {outcome: decimal price}."""
weights = weights or {}
acc = [0.0] * len(outcomes)
total_w = 0.0

for book, prices in books.items():
    ordered = [prices[o] for o in outcomes]
    fair = devig(ordered)
    if fair is None:
        continue
    w = weights.get(book, 1.0)
    acc = [a + w * f for a, f in zip(acc, fair)]
    total_w += w

if total_w == 0:
    raise ValueError("no usable bookmakers")
avg = [a / total_w for a in acc]
s = sum(avg)
return [x / s for x in avg]
Enter fullscreen mode Exit fullscreen mode

Set weights to the names of the bookmakers you consider sharp, for example {"some_sharp_book": 3.0}. Check the bookmaker names your feed returns first, since they vary between providers.

STEP 4: EV AND KELLY

Once you have a fair probability p and a bookmaker price, the maths is short:

EV per unit staked = p x odds - 1
Kelly fraction = (p x odds - 1) / (odds - 1)

Full Kelly is only optimal if your probability is exactly right, which it never is. Most people use a fraction, such as quarter Kelly, to survive estimation errors.

python
def expected_value(p: float, odds: float) -> float:
return p * odds - 1

def kelly_fraction(p: float, odds: float, fraction: float = 0.25) -> float:
edge = p * odds - 1
if edge <= 0:
return 0.0
return fraction * edge / (odds - 1)

Using the earlier example (p = 0.50, odds = 2.10), full Kelly is (1.05 - 1) / 1.10, about 4.5% of your bankroll, and quarter Kelly is about 1.1%. Even a real edge can lose money if you stake too much of your bankroll, which is why disciplined sizing matters as much as finding the bet.

STEP 5: THE API CLIENT

Polling needs timeouts and retries, otherwise one slow response freezes your scanner.

python
import os
import time
import requests
from collections import defaultdict
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from dotenv import load_dotenv

load_dotenv()

API_KEY = os.getenv("ORBISTATS_API_KEY")
BASE_URL = "https://api.orbistats.com/v1"

SPORTS = [
"football", "basketball", "american-football", "cricket", "tennis",
"baseball", "esports", "combat-sports", "volleyball", "handball",
"ice-hockey", "golf", "horse-racing",
]

def build_session() -> requests.Session:
s = requests.Session()
s.headers.update({"Authorization": f"Bearer {API_KEY}"})
retries = Retry(total=3, backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504])
s.mount("https://", HTTPAdapter(max_retries=retries))
return s

session = build_session()

def fetch_odds(sport: str) -> list[dict]:
res = session.get(f"{BASE_URL}/{sport}/odds", timeout=10)
res.raise_for_status()
return res.json().get("data", [])

The sport slugs for volleyball, handball and ice hockey follow the naming pattern of the others, so confirm the exact spelling in the documentation. Also check the real response shape in the sandbox, because the next function assumes one and it is the only place you should need to change.

STEP 6: THE +EV SCANNER

The key idea is leave-one-out: to judge a bookmaker's price, build the fair price only from the other bookmakers. If you include the bookmaker you are testing, its own error leaks into the fair price and hides the edge.

python
def find_ev_bets(events, min_ev=0.02, min_other_books=3, weights=None):
picks = []
for event in events:
name = f'{event["home"]["name"]} vs {event["away"]["name"]}'
for market in event.get("markets", []):
books = defaultdict(dict)
for line in market.get("outcomes", []):
price = line.get("price")
if price and price > 1.0:
books[line["bookmaker"]][line["name"]] = float(price)

        outcomes = sorted({o for b in books.values() for o in b})
        complete = {b: p for b, p in books.items()
                    if set(p) == set(outcomes)}
        if len(outcomes) < 2 or len(complete) < min_other_books + 1:
            continue

        for book, prices in complete.items():
            others = {b: p for b, p in complete.items() if b != book}
            fair = consensus_fair_probs(others, outcomes, weights)
            for outcome, p in zip(outcomes, fair):
                odds = prices[outcome]
                ev = expected_value(p, odds)
                if ev >= min_ev:
                    picks.append({
                        "event": name,
                        "market": market["key"],
                        "outcome": outcome,
                        "bookmaker": book,
                        "odds": odds,
                        "fair_prob": round(p, 4),
                        "fair_odds": round(1 / p, 3),
                        "ev": ev,
                        "kelly": kelly_fraction(p, odds),
                    })
return sorted(picks, key=lambda x: x["ev"], reverse=True)
Enter fullscreen mode Exit fullscreen mode

def scan_all(bankroll=1000.0):
for sport in SPORTS:
try:
events = fetch_odds(sport)
except requests.RequestException as err:
print(f"[{sport}] fetch failed: {err}")
continue
for pick in find_ev_bets(events):
stake = round(bankroll * pick["kelly"], 2)
print(f'[{sport}] {pick["event"]} | {pick["outcome"]} '
f'@ {pick["odds"]} ({pick["bookmaker"]}) '
f'fair {pick["fair_odds"]} EV {pick["ev"]:+.1%} '
f'stake {stake}')

if name == "main":
while True:
scan_all()
time.sleep(30)

Two safety filters are built in. min_ev = 0.02 ignores tiny edges that vanish after rounding and price movement, and min_other_books = 3 refuses to trust a fair price built from only one or two bookmakers.

STEP 7: TRACK CLOSING LINE VALUE

How do you know your finder works before you have hundreds of bets? Use closing line value (CLV). The closing price, after the market has absorbed all the information and money, is usually the most accurate probability estimate. If you consistently take prices that are better than the closing no-vig price, your process has an edge, even before results confirm it.

python
import csv
from datetime import datetime, timezone

def log_pick(pick, path="picks.csv"):
with open(path, "a", newline="") as f:
w = csv.writer(f)
w.writerow([datetime.now(timezone.utc).isoformat(),
pick["event"], pick["market"], pick["outcome"],
pick["bookmaker"], pick["odds"], pick["fair_prob"]])

def closing_line_value(odds_taken: float, closing_fair_prob: float) -> float:
return odds_taken * closing_fair_prob - 1

Log each pick when you find it, then after the match starts, fetch the closing prices, compute the closing no-vig probability for that outcome, and calculate CLV. A positive average CLV over a few hundred picks is much stronger evidence than a short winning streak. The Historical Sports Data API is useful here because you can replay past odds and results to backtest your thresholds before risking anything.

STEP 8: GO REAL-TIME WITH WEBSOCKET

+EV edges are usually shorter-lived than people expect, because other bettors and bots are looking at the same gaps. Polling every 30 seconds will miss many of them. Orbistats provides a WebSocket API that pushes updates as prices change, and the Live Scores API page explains how live match state fits in.

python
import asyncio
import json
import websockets

WS_URL = os.getenv("ORBISTATS_WS_URL")
latest = {}

async def stream():
while True:
try:
async with websockets.connect(
WS_URL,
additional_headers={"Authorization": f"Bearer {API_KEY}"},
) as ws:
print("Live feed connected")
async for message in ws:
event = json.loads(message)
latest[str(event["id"])] = event
for pick in find_ev_bets([event]):
print("EV:", pick["event"], pick["outcome"],
f'{pick["ev"]:+.1%}')
except Exception as err:
print(f"Disconnected ({err}), retrying in 3s...")
await asyncio.sleep(3)

asyncio.run(stream())

The reconnect loop matters: sockets drop on network hiccups and server restarts, and a scanner that silently goes stale is worse than one that never started. If you would rather receive server-side push without holding a socket open, the Webhooks API can call your endpoint when odds change. There is also a Go webhook receiver walkthrough on the Orbistats dev.to profile, and a 50-line scoreboard tutorial that shows browser-side auto-reconnect with a small Node relay keeping your API key off the client.

MAKING IT PRODUCTION-READY

Freshness check: ignore any quote older than a few seconds. A stale price at one bookmaker looks exactly like +EV.
Deduplication: hash each pick and alert only when it first appears or the EV changes meaningfully.
Market matching: make sure "Over 2.5 goals" means the same bet at every bookmaker before comparing them. Normalized markets help a lot, but always verify the market key.
Context filters: combine odds with fixtures, standings and form from the Sports Data API and the Sports Statistics API to skip matches with lineup uncertainty or postponement risk.
Watch API changes: versioning is documented, and breaking changes are meant to land behind new version numbers, so keep an eye on the changelog.
Add a dashboard: swap the terminal for a UI. Orbistats also ships embeddable widgets, and the live scoreboard tutorial shows the WebSocket pattern in vanilla JS.

COMMON MISTAKES

Trusting one bookmaker as the truth. A fair price from a single source is just that source's opinion. Use several, and weight the sharp ones higher.

Including the tested bookmaker in its own fair price. This hides real edges and creates false ones. Always use leave-one-out.

Treating fair odds as certainty. A 50.0% fair probability is an estimate with error bars. Use fractional Kelly and cap your maximum stake.

Ignoring sport differences. Tennis and volleyball are two-way markets, while football and ice hockey often add a draw outcome in 1X2-style markets. The code above handles any number of outcomes, but check the market key per sport.

Judging by a few results. A +EV bet loses a large share of the time. Judge your finder by CLV and by hundreds of bets, never by one weekend.

WHERE TO GO NEXT

You now have a finder that removes the vig with three methods, builds a leave-one-out consensus fair price, calculates EV and fractional Kelly stakes across 13 sports, and measures itself with closing line value. From here you could add line-movement tracking, per-bookmaker weights learned from your own CLV data, or a small API wrapper like the one in the Python and FastAPI tutorial so other services can query your signals. If you work in C#, there is also a .NET Core version of the sports data tutorial.

Useful links to keep open while you build:

Orbistats homepage
Pricing and free tier
Full documentation
API Sandbox
About Orbistats

If you build something on top of this, share it in the comments. I would especially like to hear which vig-removal method and which sharp bookmakers worked best for you.

Disclaimer: this article is for educational purposes only and is not financial or betting advice. Betting involves risk, may be restricted in your jurisdiction, and you should only stake what you can afford to lose.


Enter fullscreen mode Exit fullscreen mode

Top comments (0)