Same-Game Parlay Correlation: Why Multiplying Odds Fails (Python)

Same-Game Parlay Correlation - OddsPapi API Blog
How To Guides June 8, 2026

Building a same-game parlay (SGP) tool and stuck on the pricing? Here’s the trap almost everyone hits first: you grab two leg odds — say Over 2.5 goals at 1.55 and Both Teams To Score at 1.95 — multiply them, and call it a parlay price of 3.02. That number is wrong, and not by a rounding error. On a real fixture it can be off by 30–50%.

The reason is correlation. SGP legs sit on the same match, so they are not independent events. “Over 2.5 goals” and “both teams score” rise and fall together. Multiply their prices as if they were two separate coin flips and you systematically misprice the ticket — which is exactly why every sportsbook prices same-game parlays on a separate engine and blocks you from building them leg-by-leg.

This guide shows you the math, then builds a correlation-aware SGP pricer in Python. Every number below is computed live from Pinnacle‘s de-vigged lines pulled through the OddsPapi free tier — one of the 350+ bookmakers you can query from a single endpoint.

Why You Can’t Just Multiply Parlay Odds

A standard (cross-game) parlay multiplies because the legs are independent: the result of a Lakers game tells you nothing about a Premier League match. P(A and B) = P(A) × P(B) holds.

A same-game parlay breaks that assumption. Consider two legs on one football match:

  • Over 2.5 goals — the match has 3+ goals.
  • BTTS Yes — both teams score at least once.

These overlap heavily. If both teams score, you already have 2 goals and are one strike away from Over 2.5. The events are positively correlated: P(A and B) > P(A) × P(B). Multiply the leg prices and your implied probability is too low, so the odds you derive are too long (too generous). A book that paid out at those odds would be bleeding money — so it doesn’t.

The flip side: Under 2.5 + BTTS Yes is negatively correlated (the only way both happen is an exact 1–1), so naive multiplication makes those odds far too short.

The Old Way vs the Correlation-Aware Way

Approach How it prices an SGP Result
Naive multiplication Multiply the two leg prices straight from the feed Wrong by 30–60% on correlated legs
Scrape a book’s SGP price Read the bundled price off one sportsbook One book, opaque margin, no fair benchmark, breaks on layout change
Generic odds API Mainlines only, ~40 books, no sharp anchor No Pinnacle line to de-vig against
OddsPapi + a goals model De-vig Pinnacle’s 1X2 + O/U, fit a goals model, compute the true joint probability A fair, correlation-aware price you can compare to any book

We can’t read a book’s internal SGP engine. But we can rebuild the fair price ourselves: the goal-line markets (Over/Under 0.5, 1.5, 2.5, 3.5) and the 1X2 line together pin down a goals distribution, and from that distribution every joint probability is exact. Pinnacle is the sharpest public anchor for this, and OddsPapi exposes its full native market ladder.

The Tutorial: Build a Correlation-Aware SGP Pricer

Step 1 — Authenticate

OddsPapi auth is a query parameter (apiKey), never a header.

import requests

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

# Smoke test
r = requests.get(f"{BASE_URL}/sports", params={"apiKey": API_KEY})
print(r.status_code)  # 200

Step 2 — Find a soccer fixture with deep market coverage

Soccer is sportId=10. Pull a date window (max 10 days) and keep fixtures that carry odds.

from datetime import datetime, timedelta, timezone

today = datetime.now(timezone.utc)
params = {
    "apiKey": API_KEY,
    "sportId": 10,
    "from": today.strftime("%Y-%m-%d"),
    "to": (today + timedelta(days=4)).strftime("%Y-%m-%d"),
}
fixtures = requests.get(f"{BASE_URL}/fixtures", params=params).json()
playable = [f for f in fixtures if f.get("hasOdds")]
print(len(playable), "fixtures with odds")

# Example fixture used below: France vs Ivory Coast
fixture_id = "id1000085171228780"

Step 3 — Pull the odds and grab Pinnacle’s lines

The /odds response is deeply nested. The path to a current price is
bookmakerOdds[slug]["markets"][market_id]["outcomes"][outcome_id]["players"]["0"]["price"].
We need three Pinnacle markets:

Market marketId Outcomes
Full Time Result (1X2) 101 101 Home, 102 Draw, 103 Away
Over/Under 2.5 1010 1010 Over, 1011 Under
Both Teams To Score 104 104 Yes, 105 No
book = requests.get(
    f"{BASE_URL}/odds",
    params={"apiKey": API_KEY, "fixtureId": fixture_id, "bookmakers": "pinnacle"},
).json()["bookmakerOdds"]

def price(slug, market_id, outcome_id):
    # Current decimal price, or None if missing/suspended
    try:
        p = book[slug]["markets"][str(market_id)]["outcomes"][str(outcome_id)]["players"]["0"]
        if p.get("active") and p.get("price"):
            return p["price"]
    except (KeyError, TypeError):
        pass
    return None

s = "pinnacle"
oneXtwo = [price(s, 101, 101), price(s, 101, 102), price(s, 101, 103)]  # H, D, A
ou25    = [price(s, 1010, 1010), price(s, 1010, 1011)]                  # Over, Under
btts    = [price(s, 104, 104),  price(s, 104, 105)]                     # Yes, No

Step 4 — Strip the vig

Raw prices include the bookmaker’s margin. Normalising the implied probabilities so they sum to 1 gives a fair (no-vig) estimate. (This is the simple proportional method — good enough here; see our consensus odds guide for the power method.)

def devig(prices):
    inv = [1 / p for p in prices]
    total = sum(inv)
    return [x / total for x in inv]

pH, pD, pA = devig(oneXtwo)
p_over25, _ = devig(ou25)
p_btts_yes, _ = devig(btts)

print(f"Fair 1X2:  Home {pH:.3f}  Draw {pD:.3f}  Away {pA:.3f}")
print(f"Fair Over 2.5: {p_over25:.3f}")
print(f"Fair BTTS Yes: {p_btts_yes:.3f}")

For France vs Ivory Coast this prints:

Fair 1X2:  Home 0.769  Draw 0.142  Away 0.089
Fair Over 2.5: 0.642
Fair BTTS Yes: 0.485

Step 5 — Fit a goals model

To get joint probabilities we need the full distribution of (home goals, away goals), not just the marginals. The standard, transparent choice is an independent Poisson model: pick a scoring rate for each team (lam_home, lam_away) so the model reproduces Pinnacle’s fair 1X2 and Over 2.5. A small grid search does it.

import math

def poisson_matrix(lh, la, kmax=14):
    grid = {}
    for x in range(kmax):
        for y in range(kmax):
            px = math.exp(-lh) * lh**x / math.factorial(x)
            py = math.exp(-la) * la**y / math.factorial(y)
            grid[(x, y)] = px * py
    return grid

def region(grid, cond):
    return sum(p for (x, y), p in grid.items() if cond(x, y))

def fit_lambdas(target_home, target_away, target_over25):
    best, lh = None, 0.2
    while lh < 3.6:
        la = 0.2
        while la < 3.6:
            g = poisson_matrix(lh, la)
            mH = region(g, lambda x, y: x > y)
            mA = region(g, lambda x, y: x < y)
            mO = region(g, lambda x, y: x + y >= 3)
            err = (mH - target_home)**2 + (mA - target_away)**2 + (mO - target_over25)**2
            if best is None or err < best[0]:
                best = (err, lh, la)
            la += 0.02
        lh += 0.02
    return best[1], best[2]

lam_home, lam_away = fit_lambdas(pH, pA, p_over25)
grid = poisson_matrix(lam_home, lam_away)
print(f"lam_home={lam_home:.2f}  lam_away={lam_away:.2f}")  # 2.56  0.74

The fitted model reproduces the market almost exactly (model Home 0.767 vs fair 0.769, BTTS 0.482 vs 0.485) — so we trust it for the joint.

Step 6 — Compare naive vs true joint odds

Define each leg as a condition on (home, away) goals, then price any pair two ways: the naive product, and the true joint from the model.

legs = {
    "Over 2.5":  lambda x, y: x + y >= 3,
    "Under 2.5": lambda x, y: x + y < 3,
    "BTTS Yes":  lambda x, y: x >= 1 and y >= 1,
    "Home win":  lambda x, y: x > y,
}

def sgp(grid, leg_a, leg_b):
    pa = region(grid, legs[leg_a])
    pb = region(grid, legs[leg_b])
    pj = region(grid, lambda x, y: legs[leg_a](x, y) and legs[leg_b](x, y))
    naive = 1 / (pa * pb)
    fair = 1 / pj
    return naive, fair, (pj / (pa * pb) - 1)

for a, b in [("Over 2.5", "BTTS Yes"), ("Under 2.5", "BTTS Yes"),
             ("Over 2.5", "Home win"), ("Under 2.5", "Home win")]:
    naive, fair, lift = sgp(grid, a, b)
    print(f"{a:9} + {b:9}: naive {naive:5.2f} | fair {fair:5.2f} | correlation {lift:+.1%}")

The Results: Correlation Is Huge

Output for France vs Ivory Coast (Pinnacle, fitted lam_home=2.56, lam_away=0.74):

Same-game parlay Naive odds Fair odds Correlation effect
Over 2.5 + BTTS Yes 3.24 2.42 +33.5% (positive)
Under 2.5 + BTTS Yes 5.77 14.31 −59.7% (negative)
Over 2.5 + Home win 2.04 1.81 +12.3% (positive)
Under 2.5 + Home win 3.63 4.65 −21.9% (negative)

Read the first row carefully. If a book let you build Over 2.5 + BTTS Yes by multiplying the mainline prices, it would quote 3.24 — but the correlation-adjusted fair price is 2.42. That's a 33% overlay handed to the bettor. No book does this; they re-price the bundle (or refuse it). The last column is exactly the knob a sportsbook's SGP engine turns.

The effect is not a quirk of one lopsided match. The same script on two more friendlies:

Fixture Over 2.5 + BTTS Yes (naive → fair) Under 2.5 + BTTS Yes (naive → fair)
France vs Ivory Coast 3.24 → 2.42 (+33.5%) 5.77 → 14.31 (−59.7%)
Sweden vs Greece 3.67 → 2.41 (+52.7%) 3.84 → 8.57 (−55.2%)
Mexico vs Serbia 4.09 → 2.82 (+45.0%) 5.28 → 12.57 (−58.0%)

Across every fixture, "goals + both teams score" is strongly positively correlated and "no goals + both teams score" is strongly negatively correlated. Multiplying leg odds is never close.

Where This Fits in a Betting Stack

This pricer gives you a fair, model-based SGP number. Three honest caveats before you point it at real money:

  • Model risk. Independent Poisson ignores in-match dynamics (a team chasing a goal changes both rates). It matches the market well, but it is a model, not truth. Bivariate-Poisson or Dixon–Coles refinements tighten the low-score cells.
  • You still can't see the book's SGP price. OddsPapi carries mainline markets, not bundled SGP quotes. This tells you the fair price; whether a given book's SGP beats it is a separate scrape.
  • Margins compound. Books load extra vig into SGPs precisely because they know retail bettors multiply. Your fair number is the benchmark to measure that load against.

From here, size any edge with our Kelly criterion calculator, fold it into an expected-value workflow, and reuse the parser from the player props value scanner. New to the soccer endpoints? Start with the football odds API guide, then add line shopping across 350+ books to find the book offering the longest legs.

Why OddsPapi for This

  • Pinnacle, the sharp anchor. You need a low-margin, efficient line to de-vig against. OddsPapi exposes Pinnacle's full native market ladder — 1X2, the complete Over/Under ladder, Asian handicaps — on one endpoint.
  • 350+ bookmakers. Once you have a fair SGP price, line-shop the individual legs across the network to maximise the bundle.
  • Free historical odds. Backtest your goals model against closing lines without paying for a data warehouse — the free tier includes /historical-odds.

Stop multiplying odds and hoping. Get your free OddsPapi API key and price your parlays the way the books do.

Frequently Asked Questions

Why can't I just multiply same-game parlay odds?

Because the legs are on the same match and therefore correlated. Multiplication assumes independence, which only holds for legs on different games. On correlated legs like Over 2.5 + BTTS Yes, the true joint probability is 30–50% higher than the product of the marginals, so multiplied odds are badly mispriced.

How do bookmakers price same-game parlays?

They run a correlation model (often a goals/score-distribution model like the Poisson approach in this guide) to compute the true joint probability, then add margin. That's why you can't freely combine correlated legs at multiplied prices — the SGP engine re-prices the bundle.

What data do I need to price an SGP correctly?

A sharp goals line. The 1X2 market plus the Over/Under ladder pin down a goals distribution; from that distribution every joint probability is exact. This guide pulls Pinnacle's lines through the OddsPapi free tier.

Is Poisson good enough for football SGPs?

Independent Poisson is a transparent baseline that matches market prices closely and is fine for understanding correlation magnitude. For production pricing, refine it with a bivariate-Poisson or Dixon–Coles adjustment to better capture low-scoring outcomes.

Can OddsPapi return a sportsbook's bundled SGP price?

No — OddsPapi carries mainline markets (1X2, totals, BTTS, handicaps, player props), not the combined SGP quote a book shows in its bet slip. Use this model to compute the fair SGP price, then compare it to whatever a book offers.