Matched betting milks sign-up offers once and it is over. Positive-EV betting is the ongoing version: you bet only when a recreational sportsbook (DraftKings, FanDuel, BetMGM, Caesars, Fanatics) is paying better odds than the true probability says it should.

How do you know the “true probability”? You let the sharpest bookmaker in the world tell you. Pinnacle takes huge limits, welcomes winning players, and moves its lines when sharp money hits them. Its prices, with the margin stripped out (“devigged”), are the best free estimate of real odds. When FanDuel still has a team at +120 and Pinnacle’s no-vig price says fair is +105, that bet is worth about 7% of your stake in expectation. Do that a few thousand times and the math wins.

The catch, and the reason this is grey-area: soft books do not want you. They will limit your stakes to a few dollars once their risk team flags you, usually within weeks. The whole game is extracting as much edge as possible from each account before that happens, without ever breaking the law to extend it.

This guide covers the devig math, a Python scanner built on The Odds API, Kelly staking, closing line value (the only honest scoreboard), a real cost and earnings breakdown, and the risks.

Who this works for (read first)

  • US residents in legal states: about 30+ states plus DC have legal online sportsbooks. More books in your state = more accounts to extract from before limits. New Jersey, New York, Pennsylvania, Illinois, Arizona, Colorado, Virginia and Michigan have deep markets. You must be physically located in the state when you bet (books geolocate you on every bet).
  • UK / Ireland: the method works the same (with UK books instead of the US list in the code), but UK soft books limit faster and you have fewer of them per edge. Matched betting is usually the better first move there.
  • Indonesia and most of Southeast Asia: online gambling is illegal (ITE Law Article 27(2) as amended by UU 1/2024, plus KUHP Articles 426 and 427, in force since 2 January 2026). US sportsbooks also block non-residents through geolocation and KYC, and a VPN fails both. If you are not physically and legally in a jurisdiction where this is legal, read the math, run the scanner for learning, and do not place bets.

You never need a Pinnacle account. Pinnacle does not serve the US or UK anyway. You only need its prices, which The Odds API returns (listed under its eu region). The Odds API notes that Pinnacle’s odds are taken from its public website and may be delayed, which is one more reason to confirm every price before betting.

How the money works

Step 1: strip the margin from Pinnacle

Pinnacle has an NBA game at 1.91 / 1.91 (decimal, -110 / -110 in American). Implied probabilities: 1/1.91 = 52.36% each, total 104.7%. That 4.7% overround is the margin. Remove it and each side is 50.0% fair, fair odds 2.00.

The simplest method is multiplicative: divide each implied probability by the total. For lopsided lines (big favorites), the power method is more accurate because books shade more margin onto longshots. The scanner below uses power.

Step 2: compare against the soft book

FanDuel has the same game at 2.10 (+110) on one side.

EV = fair_probability × soft_odds − 1
   = 0.500 × 2.10 − 1 = +5.0%

Every $100 you put on that bet is worth $5 in expectation. You will lose it about half the time. That is fine; you are not trying to win this bet, you are trying to make 2,000 bets like it.

Step 3: size it with fractional Kelly

Full Kelly stake fraction: f = (b × p − q) / b, where b = odds − 1, p = fair probability, q = 1 − p.

f = (1.10 × 0.50 − 0.50) / 1.10 = 4.5% of bankroll

Full Kelly is too aggressive because your “fair” probability is itself an estimate. Use quarter Kelly: 1.1% of bankroll, so $57 on a $5,000 bankroll. Cap any single bet at 2% no matter what the formula says.

Step 4: scale

Expected profit = turnover × average edge. That is the only formula that matters:

BankrollBets/monthAvg stakeTurnoverAt 3% avg edge
$2,000300$30$9,000$270
$5,000450$75$33,750$1,010
$15,000600$200$120,000$3,600
$30,000700$350$245,000$7,350

The bottom two rows are only reachable while you still have unlimited accounts. A $350 bet at DraftKings on an NBA side is normal; the same stake on a WNBA player prop gets you flagged fast.

What you need

ItemCostNotes
Sportsbook accountsFreeEvery legal book in your state, in your own name, verified with your own ID
Bankroll$2,000 minimum, $5,000–$15,000 to matterSplit across books. Money sitting in a book you cannot bet at is dead money
The Odds API keyFree (500 credits/month); $30/month (20,000); $59/month (100,000)Caesars (williamhill_us) and Fanatics are only on paid plans. Up to 10 bookmakers named in the bookmakers parameter cost 1 credit per market per request
Python 3.10+Freepip install requests
VPS (optional)~$5/monthHetzner or DigitalOcean, to scan every few minutes all day
Telegram bot (optional)FreeAlerts to your phone; you still place every bet by hand
Paid screener (optional)roughly $100–$400+/monthOddsJam, Outlier, Unabated. Faster data, props coverage. Check current pricing; only worth it once you are past ~$50k monthly turnover
Free devig calculatorFreeCrazy Ninja Odds devigger, to double-check a price before betting

Credit budget: the scanner below names exactly 10 bookmakers, so each sport × market costs 1 credit. 4 sports, moneyline only, every 5 minutes for 14 hours a day is ~670 credits/day, about 20,000/month: right at the ceiling of the $30 plan. Add spreads and totals and you triple that, so take the $59 plan. The free 500 credits are enough to test the code for a few days, not to run it.

Step 1: Check coverage

export ODDS_API_KEY="your_key_here"
curl -s "https://api.the-odds-api.com/v4/sports?apiKey=$ODDS_API_KEY" \
  | python3 -c "import sys,json; [print(s[\"key\"]) for s in json.load(sys.stdin) if s[\"active\"]]"

Listing sports is free. Use keys like basketball_nba, americanfootball_nfl, icehockey_nhl, baseball_mlb. Main markets (moneyline, spread, total) on major leagues are where Pinnacle is sharpest. Its lines on obscure leagues and early-week props are much less reliable.

Step 2: The scanner

Save as ev_scan.py:

import csv
import os
import sys
from datetime import datetime, timezone

import requests

KEY = os.environ["ODDS_API_KEY"]
URL = "https://api.the-odds-api.com/v4/sports/{sport}/odds"
# 9 soft books + Pinnacle = 10 bookmakers = 1 credit per market per request.
# espnbet is theScore Bet (formerly ESPN Bet); williamhill_us is Caesars.
SOFT = {"draftkings", "fanduel", "betmgm", "williamhill_us", "fanatics",
        "espnbet", "betrivers", "ballybet", "hardrockbet"}
BOOKMAKERS = ",".join(sorted(SOFT | {"pinnacle"}))
MIN_EV = 0.02          # ignore anything under +2%
MAX_STALE_SEC = 120    # skip quotes older than 2 minutes
BANKROLL = float(os.environ.get("BANKROLL", 5000))
KELLY_FRACTION = 0.25
MAX_BET_PCT = 0.02


def devig_power(odds):
    """Find k so that sum((1/o)^k) == 1. Returns fair probabilities."""
    implied = [1 / o for o in odds]
    lo, hi = 1.0, 3.0
    for _ in range(60):
        k = (lo + hi) / 2
        if sum(p ** k for p in implied) > 1:
            lo = k
        else:
            hi = k
    return [p ** k for p in implied]


def kelly_stake(p, odds):
    b = odds - 1
    f = (b * p - (1 - p)) / b
    f = max(0.0, f) * KELLY_FRACTION
    return round(min(f, MAX_BET_PCT) * BANKROLL)


def age_seconds(ts):
    t = datetime.fromisoformat(ts.replace("Z", "+00:00"))
    return (datetime.now(timezone.utc) - t).total_seconds()


def scan(sport, market="h2h"):
    r = requests.get(URL.format(sport=sport), params={
        "apiKey": KEY, "bookmakers": BOOKMAKERS, "markets": market,
        "oddsFormat": "decimal",
    }, timeout=20)
    r.raise_for_status()
    print(f"[{sport}] credits left: {r.headers.get('x-requests-remaining')}")

    hits = []
    for ev in r.json():
        books = {b["key"]: b for b in ev["bookmakers"]}
        pin = books.get("pinnacle")
        if not pin:
            continue
        pm = next((m for m in pin["markets"] if m["key"] == market), None)
        if not pm or age_seconds(pm["last_update"]) > MAX_STALE_SEC:
            continue
        # key outcomes by name + point so spreads/totals line up
        keyed = {(o["name"], o.get("point")): o["price"] for o in pm["outcomes"]}
        fair = dict(zip(keyed, devig_power(list(keyed.values()))))

        for bk, book in books.items():
            if bk not in SOFT:
                continue
            for m in book["markets"]:
                if m["key"] != market or age_seconds(m["last_update"]) > MAX_STALE_SEC:
                    continue
                for o in m["outcomes"]:
                    k = (o["name"], o.get("point"))
                    if k not in fair:
                        continue  # different line than Pinnacle, skip
                    p = fair[k]
                    edge = p * o["price"] - 1
                    if edge >= MIN_EV:
                        hits.append({
                            "time": datetime.now(timezone.utc).isoformat(timespec="seconds"),
                            "sport": sport, "event_id": ev["id"],
                            "game": f"{ev['away_team']} @ {ev['home_team']}",
                            "start": ev["commence_time"], "market": market,
                            "pick": f"{o['name']} {'' if o.get('point') is None else o['point']}".strip(),
                            "book": book["title"], "odds": o["price"],
                            "fair_odds": round(1 / p, 3), "ev_pct": round(edge * 100, 2),
                            "stake": kelly_stake(p, o["price"]),
                        })
    return sorted(hits, key=lambda h: -h["ev_pct"])


if __name__ == "__main__":
    sports = sys.argv[1:] or ["basketball_nba", "icehockey_nhl"]
    rows = [h for s in sports for h in scan(s)]
    for h in rows:
        print(f"+{h['ev_pct']:>5}%  {h['book']:<12} {h['pick']:<28} "
              f"{h['odds']:<5} fair {h['fair_odds']:<6} stake ${h['stake']}  {h['game']}")
    if rows:
        new = not os.path.exists("signals.csv")
        with open("signals.csv", "a", newline="") as f:
            w = csv.DictWriter(f, fieldnames=rows[0].keys())
            if new:
                w.writeheader()
            w.writerows(rows)

Run it:

python3 ev_scan.py basketball_nba icehockey_nhl

Sample output looks like this (illustrative, not a live market):

[basketball_nba] credits left: 19842
+ 4.81%  FanDuel      Orlando Magic                2.25  fair 2.147  stake $48  Orlando Magic @ Chicago Bulls
+ 2.36%  BetMGM       Under 221.5                  1.95  fair 1.905  stake $31  ...

Two details that matter more than they look:

  1. The stale filter. A huge “edge” is usually one side of a quote that has not refreshed. The Odds API is not tick-by-tick, so the code skips any market whose last_update is over two minutes old (the market-level field; the bookmaker-level one is deprecated in the API docs). Always confirm the price in the sportsbook app before you bet. If it moved, skip it.
  2. Matching lines. For spreads and totals, Pinnacle at -3.5 and DraftKings at -3 are different bets. The code only compares identical lines. Run spreads with scan(sport, "spreads") and totals with "totals" (each costs another credit per sport per scan).

Step 3: Automate the alerts

Cron it on a VPS every 5 minutes during game windows:

crontab -e
*/5 14-23 * * * cd /home/you/ev && /usr/bin/python3 ev_scan.py basketball_nba icehockey_nhl >> scan.log 2>&1

For phone alerts, add a Telegram sendMessage call (https://api.telegram.org/bot<TOKEN>/sendMessage) for every hit with ev_pct >= 3. Keep the betting itself manual. Automated bet placement violates every US sportsbook’s terms and is the fastest way to have an account closed with funds under review.

Step 4: Track closing line value (your real scoreboard)

Wins and losses over 200 bets are noise. Closing line value (CLV) is signal: did you get a better price than Pinnacle’s fair price right before the game started? If you consistently beat the close, you have an edge, whatever your bankroll did this week.

Save as clv.py in the same folder and cron it every 10 minutes (with cd into that folder and ODDS_API_KEY set, like the scanner). It refetches Pinnacle only (1 credit per sport per run) for logged events starting in the next 15 minutes:

import csv, os, sys
from datetime import datetime, timezone
import requests
from ev_scan import devig_power, URL, KEY

now = datetime.now(timezone.utc)
if not os.path.exists("signals.csv"):
    sys.exit()
rows = list(csv.DictReader(open("signals.csv")))
due = [r for r in rows if not r.get("close_fair") and 0 < (
    datetime.fromisoformat(r["start"].replace("Z", "+00:00")) - now).total_seconds() < 900]

for sport in {r["sport"] for r in due}:
    ids = ",".join({r["event_id"] for r in due if r["sport"] == sport})
    data = requests.get(URL.format(sport=sport), params={
        "apiKey": KEY, "bookmakers": "pinnacle", "markets": "h2h",
        "oddsFormat": "decimal", "eventIds": ids}, timeout=20).json()
    for ev in data:
        if not ev["bookmakers"]:
            continue
        outs = ev["bookmakers"][0]["markets"][0]["outcomes"]
        fair = dict(zip([o["name"] for o in outs], devig_power([o["price"] for o in outs])))
        for r in due:
            if r["event_id"] == ev["id"] and r["market"] == "h2h" and r["pick"] in fair:
                r["close_fair"] = round(1 / fair[r["pick"]], 3)
                r["clv_pct"] = round((float(r["odds"]) / r["close_fair"] - 1) * 100, 2)

fields = list(rows[0].keys()) + [k for k in ("close_fair", "clv_pct") if k not in rows[0]]
with open("signals.csv", "w", newline="") as f:
    w = csv.DictWriter(f, fieldnames=fields)
    w.writeheader()
    w.writerows(rows)

Add a placed column by hand for the bets you actually took. After 300+ placed bets, average clv_pct above +1.5% means the process works. Near zero or negative means your edges are mostly stale quotes, and you should stop before the variance teaches you the same lesson more expensively.

Honest cost breakdown

Monthly costStarterSerious
The Odds API$0–$30$59
VPS$0$5
Paid screener$0$0–$200+
Total$0–$30$64–$264+

The real cost is bankroll you can afford to see drop 30% on a bad run and your time: about 1–2 hours a day during the season, mostly confirming prices and placing bets inside the window before lines move.

Realistic earnings and variance

Assume a $5,000 bankroll, ~450 bets a month, $75 average stake, 3% average edge:

  • Expected: about $1,000/month.
  • Variance: at odds near 2.0, one standard deviation per bet is roughly your stake. Over 450 bets that is about $75 × √450 ≈ $1,590. So a single month can easily land anywhere from −$600 to +$2,600, and roughly 1 month in 4 will be a loss even when you are doing everything right.
  • Over 2,000 bets (about 4–5 months), expected profit is ~$4,500 against a standard deviation of ~$3,350, so you are positive about 9 times in 10.

The $5k+/month tier needs a $15k–$30k bankroll and enough unlimited accounts to push $150k+ monthly turnover. Most people hit that ceiling for a few months, not forever.

Risks: the real ones

Getting limited (certain). US books profile you on CLV, bet timing, market choice and stake pattern. Expect stake limits at the first books within weeks to a few months. Limiting is legal. A book can cut your max bet to $5 and you have no recourse, though you can withdraw your balance.

What experienced bettors do to last longer, all legitimate:

  • Bet mainlines (NBA/NFL sides and totals) more than niche props, which get flagged fastest.
  • Round stakes ($50, not $47.83). Kelly-precise stakes look like a bot.
  • Do not always hit a line within seconds of a move. Mix in some bets placed closer to game time.
  • Take the book’s promos and odds boosts, which are frequently +EV themselves and look like normal recreational behavior.
  • Spread action across every book instead of hammering one.

Do not cross these lines: betting through someone else’s account (“beards” or proxy betting) violates every book’s terms and is a criminal offense under gaming law in several states. Multiple accounts per person get you banned and your balance confiscated. Spoofing geolocation is a terms violation and in some states a crime. None of this is grey area. It is the part that turns a limited account into a legal problem.

Taxes (US). Gambling winnings are taxable income. Losses are only deductible if you itemize, and starting with tax year 2026 the 2025 budget law (One Big Beautiful Bill Act) caps deductible wagering losses at 90% of losses. Bills to restore the full deduction have been introduced in Congress, but as of October 2026 none has been enacted, so plan under the 90% rule. With $200k of turnover, $105k won and $100k lost on settled bets, you could owe tax on $15k of “income” despite netting $5k. Track every bet and talk to a tax professional before scaling.

Banking. Constant deposits and withdrawals to sportsbooks can show up in mortgage underwriting. Use a separate checking account.

Book insolvency or voided bets. Books can void bets at “obvious error” prices, which is exactly what a huge edge often looks like. Do not chase 15%+ edges on obscure markets.

Reality check

What does not work:

  • Trusting a single huge edge. A +12% line is usually stale data or a palpable-error void waiting to happen. Real edges are 1–6%.
  • Using a soft book as the “sharp” reference. Devigging DraftKings against FanDuel is just comparing two wrong prices. Use Pinnacle (or Circa, BetCRIS where available) and only on liquid markets.
  • Judging after 100 bets. You will be up or down by more than your expected edge. Judge by CLV, not P&L.
  • Full Kelly. One bad week at full Kelly can halve your bankroll. Quarter Kelly is not timid; it is how you survive the estimate being wrong.
  • Treating this as passive income. The scanner is automated. The betting, the limits, the bookkeeping and the tax are not.
  • Expecting it to last forever. Each account is a depleting asset. Plan to extract a few hundred to a few thousand dollars per book, then move to the next.

The realistic version: with a $5,000 bankroll and 1–2 hours a day, expect roughly $500–$1,500/month for the first several months, with losing months mixed in, tapering as books limit you. With $15k+ and every book in a deep state, a $3k–$5k month is possible while accounts are fresh. Start small, prove positive CLV over 300 bets, and only then add bankroll.